f1f1be21c7
Celebration micro-interactions: - SuccessToast: auto-dismissing pill toast (success/info/warning variants) - ConfettiBurst: pure CSS 20-particle confetti on project creation - Project wizard: confetti + toast on successful creation - Vacation approval/rejection: contextual toasts - Allocation status change: success toast - Button: active:scale-[0.97] press feedback on all variants Collapsible sidebar + responsive: - Desktop: toggle collapse (72px icons-only mode) with localStorage persistence - NavTooltip: hover labels on collapsed icons - Mobile: hamburger menu + slide-in overlay with backdrop - Auto-close sidebar on mobile navigation - Scroll-to-top on route change (smooth behavior) Hover polish + accessibility: - Table rows: animated left-border accent + hover-lift - Stat cards + widgets: hover elevation + border glow - Timeline blocks: scale(1.02) + shadow-md on hover - Smooth scroll globally with prefers-reduced-motion fallback - Filter chips: framer-motion scale+fade enter/exit - Dropdowns: scaleY origin-top reveal animation - Preferences modal: scale+fade entrance - Link underline: animated ::after width expansion on hover Co-Authored-By: claude-flow <ruv@ruv.net>
106 lines
2.7 KiB
TypeScript
106 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback } from "react";
|
|
|
|
interface ConfettiBurstProps {
|
|
trigger: boolean;
|
|
particleCount?: number;
|
|
duration?: number;
|
|
colors?: string[];
|
|
}
|
|
|
|
const DEFAULT_COLORS = [
|
|
"#6366f1", // indigo
|
|
"#8b5cf6", // violet
|
|
"#ec4899", // pink
|
|
"#f59e0b", // amber
|
|
"#10b981", // emerald
|
|
"#3b82f6", // blue
|
|
];
|
|
|
|
const KEYFRAMES_ID = "confetti-burst-keyframes";
|
|
|
|
function ensureKeyframes() {
|
|
if (typeof document === "undefined") return;
|
|
if (document.getElementById(KEYFRAMES_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = KEYFRAMES_ID;
|
|
style.textContent = `
|
|
@keyframes confetti-burst {
|
|
0% {
|
|
transform: translate(0, 0) rotate(0deg) scale(1);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
export function ConfettiBurst({
|
|
trigger,
|
|
particleCount = 20,
|
|
duration = 800,
|
|
colors = DEFAULT_COLORS,
|
|
}: ConfettiBurstProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const prevTrigger = useRef(false);
|
|
|
|
const burst = useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
ensureKeyframes();
|
|
|
|
const particles: HTMLDivElement[] = [];
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const el = document.createElement("div");
|
|
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
|
|
const distance = 40 + Math.random() * 80;
|
|
const x = Math.cos(angle) * distance;
|
|
const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias
|
|
const rotation = Math.random() * 720 - 360;
|
|
|
|
el.style.cssText = `
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
width: 6px;
|
|
height: 6px;
|
|
margin: -3px 0 0 -3px;
|
|
border-radius: ${Math.random() > 0.5 ? "50%" : "1px"};
|
|
background: ${colors[i % colors.length]};
|
|
pointer-events: none;
|
|
--confetti-x: ${x}px;
|
|
--confetti-y: ${y}px;
|
|
--confetti-r: ${rotation}deg;
|
|
animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
|
`;
|
|
container.appendChild(el);
|
|
particles.push(el);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
particles.forEach((el) => el.remove());
|
|
}, duration + 50);
|
|
}, [particleCount, duration, colors]);
|
|
|
|
useEffect(() => {
|
|
if (trigger && !prevTrigger.current) {
|
|
burst();
|
|
}
|
|
prevTrigger.current = trigger;
|
|
}, [trigger, burst]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="pointer-events-none absolute inset-0 overflow-visible"
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|