105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useRef } from 'react';
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
|
|
import { cn } from '@/lib/utils/cn';
|
||
|
|
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
|
||
|
|
import { buildCSS } from '@/lib/animate/cssBuilder';
|
||
|
|
import type { AnimationConfig, AnimationPreset } from '@/types/animate';
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
currentName: string;
|
||
|
|
onSelect: (config: AnimationConfig) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function PresetCard({ preset, isActive, onSelect }: {
|
||
|
|
preset: AnimationPreset;
|
||
|
|
isActive: boolean;
|
||
|
|
onSelect: () => void;
|
||
|
|
}) {
|
||
|
|
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||
|
|
const key = `preset-${preset.id}`;
|
||
|
|
|
||
|
|
// Each card gets its own @keyframes injected with a unique name to avoid conflicts
|
||
|
|
useEffect(() => {
|
||
|
|
const renamedConfig = {
|
||
|
|
...preset.config,
|
||
|
|
name: key,
|
||
|
|
keyframes: preset.config.keyframes,
|
||
|
|
};
|
||
|
|
if (!styleRef.current) {
|
||
|
|
styleRef.current = document.createElement('style');
|
||
|
|
document.head.appendChild(styleRef.current);
|
||
|
|
}
|
||
|
|
styleRef.current.textContent = buildCSS(renamedConfig).replace(
|
||
|
|
/animation: \S+/,
|
||
|
|
`animation: ${key}`
|
||
|
|
);
|
||
|
|
return () => {
|
||
|
|
styleRef.current?.remove();
|
||
|
|
styleRef.current = null;
|
||
|
|
};
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
onClick={onSelect}
|
||
|
|
className={cn(
|
||
|
|
'group relative flex flex-col items-center gap-2 p-3 rounded-xl border transition-all duration-200 text-left hover:border-primary/50 hover:bg-accent/30',
|
||
|
|
isActive ? 'border-primary bg-primary/5 shadow-sm shadow-primary/20' : 'border-border bg-card/50'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{/* Mini preview */}
|
||
|
|
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
|
||
|
|
<div
|
||
|
|
className="animated w-8 h-8 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
|
||
|
|
style={{ animationName: key, animationDuration: `${preset.config.duration}ms`, animationIterationCount: 'infinite', animationDirection: 'alternate' }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<span className={cn(
|
||
|
|
'text-[11px] font-medium text-center leading-tight',
|
||
|
|
isActive ? 'text-primary' : 'text-foreground/80'
|
||
|
|
)}>
|
||
|
|
{preset.name}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PresetLibrary({ currentName, onSelect }: Props) {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Presets</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Tabs defaultValue="Entrance">
|
||
|
|
<TabsList className="mb-4">
|
||
|
|
{PRESET_CATEGORIES.map((cat) => (
|
||
|
|
<TabsTrigger key={cat} value={cat} className="text-xs">
|
||
|
|
{cat}
|
||
|
|
</TabsTrigger>
|
||
|
|
))}
|
||
|
|
</TabsList>
|
||
|
|
{PRESET_CATEGORIES.map((cat) => (
|
||
|
|
<TabsContent key={cat} value={cat}>
|
||
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
||
|
|
{PRESETS.filter((p) => p.category === cat).map((preset) => (
|
||
|
|
<PresetCard
|
||
|
|
key={preset.id}
|
||
|
|
preset={preset}
|
||
|
|
isActive={preset.id === currentName}
|
||
|
|
onSelect={() => onSelect(preset.config)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</TabsContent>
|
||
|
|
))}
|
||
|
|
</Tabs>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|