You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
7.2 KiB
JavaScript

import { Loader2, CheckCircle, AlertCircle, X } from 'lucide-react';
import { useState, useEffect } from 'react';
// Animated Loading Spinner
export function LoadingSpinner({ size = 'md', className = '' }) {
const sizeClasses = {
sm: 'h-5 w-5',
md: 'h-8 w-8',
lg: 'h-12 w-12',
xl: 'h-16 w-16'
};
return (
<div className={`flex items-center justify-center ${className}`}>
<Loader2 className={`${sizeClasses[size]} text-primary-600 animate-spin`} />
</div>
);
}
// Full page loading
export function PageLoader() {
return (
<div className="flex flex-col items-center justify-center h-64">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-500 animate-pulse">Загрузка...</p>
</div>
);
}
// Skeleton loader for cards
export function CardSkeleton({ count = 1 }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="card" style={{ animationDelay: `${i * 100}ms` }}>
<div className="flex items-center mb-4">
<div className="skeleton w-12 h-12 rounded-lg" />
<div className="ml-3 flex-1">
<div className="skeleton h-4 w-3/4 mb-2 rounded" />
<div className="skeleton h-3 w-1/2 rounded" />
</div>
</div>
<div className="skeleton h-10 w-full rounded-lg" />
</div>
))}
</>
);
}
// Skeleton loader for list items
export function ListSkeleton({ count = 5 }) {
return (
<div className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex items-center p-3 bg-gray-50 rounded-lg"
style={{ animationDelay: `${i * 50}ms` }}
>
<div className="skeleton w-8 h-8 rounded-full mr-3" />
<div className="flex-1">
<div className="skeleton h-4 w-3/4 mb-1 rounded" />
<div className="skeleton h-3 w-1/2 rounded" />
</div>
</div>
))}
</div>
);
}
// Toast notification component
export function Toast({ message, type = 'success', onClose, duration = 3000 }) {
const [isVisible, setIsVisible] = useState(true);
const [isExiting, setIsExiting] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
handleClose();
}, duration);
return () => clearTimeout(timer);
}, [duration]);
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
setIsVisible(false);
onClose?.();
}, 200);
};
if (!isVisible) return null;
const typeStyles = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
info: 'bg-blue-50 border-blue-200 text-blue-800'
};
const icons = {
success: <CheckCircle className="h-5 w-5 text-green-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
warning: <AlertCircle className="h-5 w-5 text-amber-500" />,
info: <AlertCircle className="h-5 w-5 text-blue-500" />
};
return (
<div
className={`fixed bottom-20 lg:bottom-8 left-4 right-4 sm:left-auto sm:right-8 sm:w-80 z-50
${isExiting ? 'slide-exit' : 'slide-enter'}`}
>
<div className={`flex items-center p-4 rounded-lg border shadow-lg ${typeStyles[type]}`}>
<div className="flex-shrink-0 success-check">
{icons[type]}
</div>
<p className="ml-3 text-sm font-medium flex-1">{message}</p>
<button
onClick={handleClose}
className="ml-2 p-1 rounded hover:bg-black/10 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
// Animated counter component
export function AnimatedCounter({ value, duration = 1000 }) {
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
if (typeof value !== 'number') {
setDisplayValue(value);
return;
}
let startTime;
const startValue = displayValue;
const endValue = value;
const animate = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
// Ease out
const easeOut = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.round(startValue + (endValue - startValue) * easeOut);
setDisplayValue(currentValue);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [value, duration]);
return <span className="tabular-nums">{displayValue}</span>;
}
// Animated presence wrapper
export function AnimatedPresence({ children, show, className = '' }) {
const [shouldRender, setShouldRender] = useState(show);
useEffect(() => {
if (show) setShouldRender(true);
}, [show]);
const handleAnimationEnd = () => {
if (!show) setShouldRender(false);
};
if (!shouldRender) return null;
return (
<div
className={`${className} ${show ? 'slide-enter' : 'slide-exit'}`}
onAnimationEnd={handleAnimationEnd}
>
{children}
</div>
);
}
// Ripple button wrapper
export function RippleButton({ children, onClick, className = '', ...props }) {
const handleClick = (e) => {
const button = e.currentTarget;
const ripple = document.createElement('span');
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
ripple.style.width = ripple.style.height = `${size}px`;
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.className = 'absolute bg-white/30 rounded-full animate-ping pointer-events-none';
button.appendChild(ripple);
setTimeout(() => ripple.remove(), 500);
onClick?.(e);
};
return (
<button
onClick={handleClick}
className={`relative overflow-hidden ${className}`}
{...props}
>
{children}
</button>
);
}
// Pulsing dot indicator
export function PulsingDot({ color = 'green', size = 'sm' }) {
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
};
const colorClasses = {
green: 'bg-green-500',
red: 'bg-red-500',
amber: 'bg-amber-500',
blue: 'bg-blue-500'
};
return (
<span className="relative inline-flex">
<span className={`${sizeClasses[size]} ${colorClasses[color]} rounded-full`} />
<span className={`absolute ${sizeClasses[size]} ${colorClasses[color]} rounded-full animate-ping opacity-75`} />
</span>
);
}
// Progress bar
export function ProgressBar({ value, max = 100, showLabel = false, className = '' }) {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div className={`w-full ${className}`}>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary-600 rounded-full transition-all duration-500 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<p className="text-xs text-gray-500 mt-1 text-right">{Math.round(percentage)}%</p>
)}
</div>
);
}