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
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>
|
|
);
|
|
} |