refactor: align animate tool with Calculate/Media blueprint

Layout:
- AnimationEditor: lg:grid-cols-5 (2/5 edit, 3/5 visual); full viewport
  height; mobile Edit|Preview glass pill tabs; timeline embedded in edit
  panel on mobile, standalone on desktop; Export|Presets custom tab
  panel at the bottom of the right column

Components (all shadcn removed):
- AnimationSettings: Card/Label/Input/Select/Button → native inputs;
  direction & fill mode as 4-pill selectors; easing as native <select>;
  ∞ iterations as icon pill toggle
- AnimationPreview: Card/ToggleGroup/Button → glass card; speed pills
  as inline glass pill group; element picker as compact icon pills;
  playback controls as glass icon buttons; subtle grid bg on canvas
- KeyframeTimeline: Card/Button → glass card; embedded prop for
  rendering inside another card on mobile without double glass
- KeyframeProperties: Card/Label/Input/Button → bare content section;
  SliderRow uses native number input; bg color toggle as pill button
- ExportPanel: Card/Tabs/Button → bare section; CSS|Tailwind custom
  tab switcher; dark terminal (#06060e) code blocks
- PresetLibrary: Card/Tabs → bare section; category pills replace Tabs;
  preset cards use glass border-border/20 bg-primary/3 styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:48:35 +01:00
parent 50cf5823f9
commit ea464ef797
7 changed files with 598 additions and 587 deletions

View File

@@ -8,14 +8,20 @@ import { KeyframeProperties } from './KeyframeProperties';
import { PresetLibrary } from './PresetLibrary'; import { PresetLibrary } from './PresetLibrary';
import { ExportPanel } from './ExportPanel'; import { ExportPanel } from './ExportPanel';
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults'; import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
import { cn } from '@/lib/utils/cn';
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate'; import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
type MobileTab = 'edit' | 'preview';
type RightTab = 'export' | 'presets';
export function AnimationEditor() { export function AnimationEditor() {
const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG);
const [selectedId, setSelectedId] = useState<string | null>( const [selectedId, setSelectedId] = useState<string | null>(
DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id
); );
const [previewElement, setPreviewElement] = useState<PreviewElement>('box'); const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
const [mobileTab, setMobileTab] = useState<MobileTab>('edit');
const [rightTab, setRightTab] = useState<RightTab>('export');
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null; const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
@@ -35,8 +41,7 @@ export function AnimationEditor() {
const deleteKeyframe = useCallback((id: string) => { const deleteKeyframe = useCallback((id: string) => {
setConfig((c) => { setConfig((c) => {
if (c.keyframes.length <= 2) return c; if (c.keyframes.length <= 2) return c;
const next = c.keyframes.filter((k) => k.id !== id); return { ...c, keyframes: c.keyframes.filter((k) => k.id !== id) };
return { ...c, keyframes: next };
}); });
setSelectedId((prev) => { setSelectedId((prev) => {
if (prev !== id) return prev; if (prev !== id) return prev;
@@ -58,47 +63,100 @@ export function AnimationEditor() {
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id); setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
}, []); }, []);
const timelineProps = {
keyframes: config.keyframes,
selectedId,
onSelect: setSelectedId,
onAdd: addKeyframe,
onDelete: deleteKeyframe,
onMove: moveKeyframe,
};
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-4">
{/* Row 1: Settings + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch"> {/* ── Mobile tab switcher ─────────────────────────────── */}
<div className="lg:col-span-1"> <div className="flex lg:hidden glass rounded-xl p-1 gap-1">
<AnimationSettings config={config} onChange={setConfig} /> {(['edit', 'preview'] as MobileTab[]).map((t) => (
</div> <button
<div className="lg:col-span-2"> key={t}
<AnimationPreview onClick={() => setMobileTab(t)}
config={config} className={cn(
element={previewElement} 'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
onElementChange={setPreviewElement} mobileTab === t
/> ? 'bg-primary text-primary-foreground shadow-sm'
</div> : 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'edit' ? 'Edit' : 'Preview'}
</button>
))}
</div> </div>
{/* Row 2: Keyframe Timeline */} {/* ── Main layout ─────────────────────────────────────── */}
<KeyframeTimeline <div
keyframes={config.keyframes} className="grid grid-cols-1 lg:grid-cols-5 gap-4"
selectedId={selectedId} style={{ height: 'calc(100svh - 220px)', minHeight: '660px' }}
onSelect={setSelectedId} >
onAdd={addKeyframe}
onDelete={deleteKeyframe}
onMove={moveKeyframe}
/>
{/* Row 3: Keyframe Properties + Export */} {/* Left: Settings + Properties */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch"> <div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'edit' && 'hidden lg:flex')}>
<div className="lg:col-span-1"> <div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<KeyframeProperties <div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
keyframe={selectedKeyframe}
onChange={updateKeyframeProps} <AnimationSettings config={config} onChange={setConfig} />
/>
<div className="border-t border-border/25" />
{/* Timeline — embedded inside edit panel on mobile, hidden on desktop */}
<div className="lg:hidden">
<KeyframeTimeline {...timelineProps} embedded />
</div>
<div className="lg:hidden border-t border-border/25" />
<KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} />
</div>
</div>
</div> </div>
<div className="lg:col-span-2">
<ExportPanel config={config} /> {/* Right: Preview + Timeline + Export/Presets */}
<div className={cn('lg:col-span-3 flex flex-col gap-3 overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
{/* Preview canvas */}
<AnimationPreview config={config} element={previewElement} onElementChange={setPreviewElement} />
{/* Timeline — standalone on desktop */}
<div className="hidden lg:block shrink-0">
<KeyframeTimeline {...timelineProps} />
</div>
{/* Export / Presets tab panel */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab switcher */}
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0">
{(['export', 'presets'] as RightTab[]).map((t) => (
<button
key={t}
onClick={() => setRightTab(t)}
className={cn(
'flex-1 py-1.5 rounded-md text-xs font-medium capitalize transition-all',
rightTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'export' ? 'Export' : 'Presets'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{rightTab === 'export' && <ExportPanel config={config} />}
{rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
</div>
</div>
</div> </div>
</div> </div>
{/* Row 4: Preset Library */}
<PresetLibrary onSelect={loadPreset} />
</div> </div>
); );
} }

View File

@@ -1,10 +1,8 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react'; import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { buildCSS } from '@/lib/animate/cssBuilder'; import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate'; import type { AnimationConfig, PreviewElement } from '@/types/animate';
@@ -23,13 +21,26 @@ const SPEEDS: { label: string; value: string }[] = [
{ label: '2×', value: '2' }, { label: '2×', value: '2' },
]; ];
const ELEMENTS: { value: PreviewElement; icon: React.ReactNode; title: string }[] = [
{ value: 'box', icon: <Square className="w-3 h-3" />, title: 'Box' },
{ value: 'circle', icon: <Circle className="w-3 h-3" />, title: 'Circle' },
{ value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' },
];
const actionBtn = 'flex items-center justify-center w-7 h-7 glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
const pillCls = (active: boolean) =>
cn(
'px-2 py-0.5 rounded text-[10px] font-mono transition-all',
active ? 'text-primary bg-primary/10' : 'text-muted-foreground/50 hover:text-muted-foreground'
);
export function AnimationPreview({ config, element, onElementChange }: Props) { export function AnimationPreview({ config, element, onElementChange }: Props) {
const styleRef = useRef<HTMLStyleElement | null>(null); const styleRef = useRef<HTMLStyleElement | null>(null);
const [restartKey, setRestartKey] = useState(0); const [restartKey, setRestartKey] = useState(0);
const [animState, setAnimState] = useState<AnimState>('playing'); const [animState, setAnimState] = useState<AnimState>('playing');
const [speed, setSpeed] = useState('1'); const [speed, setSpeed] = useState('1');
// Inject @keyframes CSS into document head
useEffect(() => { useEffect(() => {
if (!styleRef.current) { if (!styleRef.current) {
styleRef.current = document.createElement('style'); styleRef.current = document.createElement('style');
@@ -37,125 +48,113 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
document.head.appendChild(styleRef.current); document.head.appendChild(styleRef.current);
} }
styleRef.current.textContent = buildCSS(config); styleRef.current.textContent = buildCSS(config);
// Restart preview whenever config changes so changes are immediately visible
setAnimState('playing'); setAnimState('playing');
setRestartKey((k) => k + 1); setRestartKey((k) => k + 1);
}, [config]); }, [config]);
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { styleRef.current?.remove(); }; return () => { styleRef.current?.remove(); };
}, []); }, []);
const restart = () => { const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
setAnimState('playing');
setRestartKey((k) => k + 1);
};
const handlePlay = () => {
if (animState === 'ended') {
// Animation finished — restart it
restart();
} else {
setAnimState('playing');
}
};
const scaledDuration = Math.round(config.duration / Number(speed)); const scaledDuration = Math.round(config.duration / Number(speed));
const isInfinite = config.iterationCount === 'infinite'; const isInfinite = config.iterationCount === 'infinite';
return ( return (
<Card className="h-full flex flex-col"> <div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> {/* Header: speed pills */}
<CardTitle>Preview</CardTitle> <div className="flex items-center justify-between shrink-0">
<ToggleGroup type="single" value={speed} onValueChange={(v) => v && setSpeed(v)} variant="outline" size="sm"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Preview</span>
<div className="flex items-center glass rounded-md border border-border/30 px-1 gap-0.5">
{SPEEDS.map((s) => ( {SPEEDS.map((s) => (
<ToggleGroupItem key={s.value} value={s.value} className="h-6 px-1.5 min-w-0 text-[10px]"> <button key={s.value} onClick={() => setSpeed(s.value)} className={pillCls(speed === s.value)}>
{s.label} {s.label}
</ToggleGroupItem> </button>
))} ))}
</ToggleGroup> </div>
</CardHeader> </div>
<CardContent className="flex-1 flex flex-col gap-4">
{/* Preview canvas */}
<div className="flex-1 min-h-52 flex items-center justify-center rounded-xl bg-gradient-to-br from-muted/20 to-muted/5 border border-border relative overflow-hidden">
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-5 pointer-events-none"
style={{
backgroundImage: 'linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
{/* Animated element */} {/* Canvas */}
<div <div
key={restartKey} className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
className="animated relative z-10" style={{
style={{ background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
animationDuration: `${scaledDuration}ms`, backgroundImage: [
animationPlayState: animState === 'paused' ? 'paused' : 'running', 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
}} 'linear-gradient(var(--border) 1px, transparent 1px)',
onAnimationEnd={() => !isInfinite && setAnimState('ended')} 'linear-gradient(90deg, var(--border) 1px, transparent 1px)',
].join(', '),
backgroundSize: 'auto, 32px 32px, 32px 32px',
}}
>
<div
key={restartKey}
className="animated relative z-10"
style={{
animationDuration: `${scaledDuration}ms`,
animationPlayState: animState === 'paused' ? 'paused' : 'running',
}}
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
>
{element === 'box' && (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
)}
{element === 'circle' && (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
)}
{element === 'text' && (
<span className="text-3xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
Hello
</span>
)}
</div>
</div>
{/* Controls: element selector + playback */}
<div className="flex items-center justify-between shrink-0">
{/* Element picker */}
<div className="flex items-center glass rounded-md border border-border/30 p-0.5 gap-0.5">
{ELEMENTS.map(({ value, icon, title }) => (
<button
key={value}
onClick={() => onElementChange(value)}
title={title}
className={cn(
'w-7 h-7 flex items-center justify-center rounded transition-all',
element === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{icon}
</button>
))}
</div>
{/* Playback */}
<div className="flex items-center gap-1">
<button
onClick={() => animState === 'ended' ? restart() : setAnimState('playing')}
disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'}
className={actionBtn}
> >
{element === 'box' && ( <Play className="w-3 h-3" />
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" /> </button>
)} <button
{element === 'circle' && ( onClick={() => setAnimState('paused')}
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" /> disabled={animState !== 'playing'}
)} title="Pause"
{element === 'text' && ( className={actionBtn}
<span className="text-4xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none"> >
Hello <Pause className="w-3 h-3" />
</span> </button>
)} <button onClick={restart} title="Restart" className={actionBtn}>
</div> <RotateCcw className="w-3 h-3" />
</button>
</div> </div>
</div>
{/* Controls */} </div>
<div className="flex items-center justify-between gap-3">
<ToggleGroup type="single" value={element} onValueChange={(v) => v && onElementChange(v as PreviewElement)} variant="outline" size="sm">
<ToggleGroupItem value="box" className="h-6 px-1.5 min-w-0" title="Box">
<Square className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="circle" className="h-6 px-1.5 min-w-0" title="Circle">
<Circle className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="text" className="h-6 px-1.5 min-w-0" title="Text">
<Type className="h-3 w-3" />
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-1.5">
<Button
size="icon-xs"
variant="outline"
onClick={handlePlay}
disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'}
>
<Play className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'}
title="Pause"
>
<Pause className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={restart}
title="Restart"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
); );
} }

View File

@@ -1,17 +1,7 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Infinity } from 'lucide-react'; import { Infinity } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { AnimationConfig } from '@/types/animate'; import type { AnimationConfig } from '@/types/animate';
interface Props { interface Props {
@@ -30,14 +20,38 @@ const EASINGS = [
{ value: 'steps(8, end)', label: 'Steps (8)' }, { value: 'steps(8, end)', label: 'Steps (8)' },
]; ];
const DIRECTIONS: { value: AnimationConfig['direction']; label: string }[] = [
{ value: 'normal', label: 'Normal' },
{ value: 'reverse', label: 'Reverse' },
{ value: 'alternate', label: 'Alt' },
{ value: 'alternate-reverse', label: 'Alt-Rev' },
];
const FILL_MODES: { value: AnimationConfig['fillMode']; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'forwards', label: 'Fwd' },
{ value: 'backwards', label: 'Bwd' },
{ value: 'both', label: 'Both' },
];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
const pillCls = (active: boolean) =>
cn(
'flex-1 py-1.5 rounded-lg border text-[10px] font-mono transition-all',
active
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
);
export function AnimationSettings({ config, onChange }: Props) { export function AnimationSettings({ config, onChange }: Props) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) => const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value }); onChange({ ...config, [key]: value });
const isInfinite = config.iterationCount === 'infinite'; const isInfinite = config.iterationCount === 'infinite';
const isCubic = config.easing === 'cubic-bezier'; const isCubic = config.easing.startsWith('cubic-bezier');
// Parse cubic-bezier values from string like "cubic-bezier(x1,y1,x2,y2)"
const cubicValues = (() => { const cubicValues = (() => {
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/); const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
if (!m) return [0.25, 0.1, 0.25, 1.0]; if (!m) return [0.25, 0.1, 0.25, 1.0];
@@ -50,167 +64,153 @@ export function AnimationSettings({ config, onChange }: Props) {
set('easing', `cubic-bezier(${v.join(',')})`); set('easing', `cubic-bezier(${v.join(',')})`);
}; };
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
return ( return (
<Card className="h-full"> <div className="space-y-4">
<CardHeader>
<CardTitle>Settings</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
</CardHeader> Settings
<CardContent className="space-y-4"> </span>
{/* Name */}
<div className="space-y-1.5"> {/* Name */}
<Label className="text-xs">Name</Label> <div>
<Input <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
value={config.name} <input
onChange={(e) => { type="text"
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, ''); value={config.name}
set('name', val || 'myAnimation'); onChange={(e) => {
}} const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
className="font-mono text-xs" set('name', val || 'myAnimation');
}}
className={inputCls}
/>
</div>
{/* Duration + Delay */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Duration (ms)</label>
<input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
className={inputCls}
/> />
</div> </div>
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Delay (ms)</label>
<input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
className={inputCls}
/>
</div>
</div>
{/* Duration + Delay */} {/* Easing */}
<div className="grid grid-cols-2 gap-3"> <div>
<div className="space-y-1.5"> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
<Label className="text-xs">Duration</Label> <select
<div className="flex items-center gap-1"> value={easingSelectValue}
<Input onChange={(e) => {
type="number" const v = e.target.value;
min={50} set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
max={10000} }}
step={50} className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
value={config.duration} >
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))} {EASINGS.map((e) => (
<option key={e.value} value={e.value} className="bg-[#1a1a2e]">
/> {e.label}
<span className="text-xs text-muted-foreground shrink-0">ms</span> </option>
</div> ))}
</div> </select>
<div className="space-y-1.5"> </div>
<Label className="text-xs">Delay</Label>
<div className="flex items-center gap-1"> {/* Cubic-bezier inputs */}
<Input {isCubic && (
type="number" <div>
min={0} <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
max={5000} cubic-bezier(P1x, P1y, P2x, P2y)
step={50} </label>
value={config.delay} <div className="grid grid-cols-4 gap-1.5">
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))} {(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label}>
/> <label className="text-[9px] text-muted-foreground/40 font-mono block mb-1">{label}</label>
<span className="text-xs text-muted-foreground shrink-0">ms</span> <input
</div> type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="w-full bg-transparent border border-border/40 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 text-center"
/>
</div>
))}
</div> </div>
</div> </div>
)}
{/* Easing */} {/* Iterations */}
<div className="space-y-1.5"> <div>
<Label className="text-xs">Easing</Label> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
<Select <div className="flex gap-1.5">
value={isCubic ? 'cubic-bezier' : config.easing} <input
onValueChange={(v) => { type="number"
if (v === 'cubic-bezier') { min={1}
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)'); max={999}
} else { value={isInfinite ? '' : (config.iterationCount as number)}
set('easing', v); disabled={isInfinite}
} onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
}} placeholder="1"
className={cn(inputCls, 'flex-1', isInfinite && 'opacity-30')}
/>
<button
onClick={() => set('iterationCount', isInfinite ? 1 : 'infinite')}
title="Toggle infinite"
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg border text-xs transition-all shrink-0',
isInfinite
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/40 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
> >
<SelectTrigger className="w-full"> <Infinity className="w-3.5 h-3.5" />
<SelectValue /> </button>
</SelectTrigger>
<SelectContent>
{EASINGS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div>
{/* Cubic-bezier inputs */} {/* Direction */}
{isCubic && ( <div>
<div className="space-y-1.5"> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label> <div className="flex gap-1">
<div className="grid grid-cols-4 gap-1.5"> {DIRECTIONS.map(({ value, label }) => (
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => ( <button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
<div key={label} className="space-y-0.5"> {label}
<Label className="text-[10px] text-muted-foreground">{label}</Label> </button>
<Input ))}
type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="text-xs px-1.5"
/>
</div>
))}
</div>
</div>
)}
{/* Iteration */}
<div className="space-y-1.5">
<Label className="text-xs">Iterations</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={999}
value={isInfinite ? '' : config.iterationCount}
disabled={isInfinite}
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
className="text-xs flex-1"
placeholder="1"
/>
<Button
size="icon-xs"
variant={isInfinite ? 'default' : 'outline'}
onClick={() =>
set('iterationCount', isInfinite ? 1 : 'infinite')
}
title="Toggle infinite"
>
<Infinity className="h-3 w-3" />
</Button>
</div>
</div> </div>
</div>
{/* Direction */} {/* Fill Mode */}
<div className="space-y-1.5"> <div>
<Label className="text-xs">Direction</Label> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}> <div className="flex gap-1">
<SelectTrigger className="w-full"> {FILL_MODES.map(({ value, label }) => (
<SelectValue /> <button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
</SelectTrigger> {label}
<SelectContent> </button>
<SelectItem value="normal">Normal</SelectItem> ))}
<SelectItem value="reverse">Reverse</SelectItem>
<SelectItem value="alternate">Alternate</SelectItem>
<SelectItem value="alternate-reverse">Alternate Reverse</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div>
{/* Fill Mode */} </div>
<div className="space-y-1.5">
<Label className="text-xs">Fill Mode</Label>
<Select value={config.fillMode} onValueChange={(v) => set('fillMode', v as AnimationConfig['fillMode'])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="forwards">Forwards</SelectItem>
<SelectItem value="backwards">Backwards</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
); );
} }

View File

@@ -1,11 +1,9 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder'; import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig } from '@/types/animate'; import type { AnimationConfig } from '@/types/animate';
@@ -13,6 +11,11 @@ interface Props {
config: AnimationConfig; config: AnimationConfig;
} }
type ExportTab = 'css' | 'tailwind';
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all';
function CodeBlock({ code, filename }: { code: string; filename: string }) { function CodeBlock({ code, filename }: { code: string; filename: string }) {
const copy = () => { const copy = () => {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
@@ -31,49 +34,50 @@ function CodeBlock({ code, filename }: { code: string; filename: string }) {
}; };
return ( return (
<div className="space-y-3"> <div className="space-y-2">
<div className="relative"> <div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
<pre className="p-4 rounded-xl bg-muted/30 border border-border text-xs font-mono leading-relaxed overflow-auto max-h-72 text-foreground/90 whitespace-pre scrollbar"> <pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed max-h-64">
<code>{code}</code> <code>{code}</code>
</pre> </pre>
</div> </div>
<div className="flex flex-col md:flex-row gap-3"> <div className="flex gap-2">
<Button variant="outline" onClick={copy} className="w-full md:flex-1"> <button onClick={copy} className={cn(actionBtn, 'flex-1')}>
<Copy className="h-3.5 w-3.5 mr-1.5" /> <Copy className="w-3 h-3" />Copy
Copy </button>
</Button> <button onClick={download} className={cn(actionBtn, 'flex-1')}>
<Button onClick={download} className="w-full md:flex-1"> <Download className="w-3 h-3" />Download .css
<Download className="h-3.5 w-3.5 mr-1.5" /> </button>
Download .css
</Button>
</div> </div>
</div> </div>
); );
} }
export function ExportPanel({ config }: Props) { export function ExportPanel({ config }: Props) {
const [tab, setTab] = useState<ExportTab>('css');
const css = useMemo(() => buildCSS(config), [config]); const css = useMemo(() => buildCSS(config), [config]);
const tailwind = useMemo(() => buildTailwindCSS(config), [config]); const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
return ( return (
<Card className="h-full"> <div className="space-y-3">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle>Export</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
</CardHeader> <div className="flex glass rounded-lg p-0.5 gap-0.5">
<CardContent> {(['css', 'tailwind'] as ExportTab[]).map((t) => (
<Tabs defaultValue="css"> <button
<TabsList className="mb-4"> key={t}
<TabsTrigger value="css" className="text-xs">Plain CSS</TabsTrigger> onClick={() => setTab(t)}
<TabsTrigger value="tailwind" className="text-xs">Tailwind v4</TabsTrigger> className={cn(
</TabsList> 'px-2.5 py-1 rounded-md text-[10px] font-mono transition-all',
<TabsContent value="css"> tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
<CodeBlock code={css} filename={`${config.name}.css`} /> )}
</TabsContent> >
<TabsContent value="tailwind"> {t === 'css' ? 'Plain CSS' : 'Tailwind v4'}
<CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} /> </button>
</TabsContent> ))}
</Tabs> </div>
</CardContent> </div>
</Card> {tab === 'css' && <CodeBlock code={css} filename={`${config.name}.css`} />}
{tab === 'tailwind' && <CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />}
</div>
); );
} }

View File

@@ -1,10 +1,6 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { MousePointerClick } from 'lucide-react'; import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate'; import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
@@ -28,26 +24,20 @@ interface SliderRowProps {
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) { function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
return ( return (
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center"> <div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-1"> <div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground"> <label className="text-[9px] text-muted-foreground/50 font-mono">
{label}{unit && <span className="text-muted-foreground/50"> ({unit})</span>} {label}{unit && <span className="opacity-50"> ({unit})</span>}
</Label> </label>
<Slider <Slider min={min} max={max} step={step} value={[value]} onValueChange={([v]) => onChange(v)} />
min={min}
max={max}
step={step}
value={[value]}
onValueChange={([v]) => onChange(v)}
/>
</div> </div>
<Input <input
type="number" type="number"
min={min} min={min}
max={max} max={max}
step={step} step={step}
value={value} value={value}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-xs px-1.5 h-7 mt-4" className="w-14 bg-transparent border border-border/40 rounded-md px-1.5 py-1 text-[10px] font-mono text-center outline-none focus:border-primary/50 transition-colors text-foreground/80 mt-4"
/> />
</div> </div>
); );
@@ -56,15 +46,12 @@ function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderR
export function KeyframeProperties({ keyframe, onChange }: Props) { export function KeyframeProperties({ keyframe, onChange }: Props) {
if (!keyframe) { if (!keyframe) {
return ( return (
<Card className="h-full"> <div className="flex flex-col items-center justify-center py-12 text-center gap-3">
<CardHeader> <MousePointerClick className="w-7 h-7 text-muted-foreground/20" />
<CardTitle>Properties</CardTitle> <p className="text-[10px] text-muted-foreground/40 font-mono leading-relaxed max-w-[180px]">
</CardHeader> Select a keyframe on the timeline to edit its properties
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> </p>
<MousePointerClick className="h-8 w-8 mx-auto mb-3 opacity-20" /> </div>
<p className="text-xs text-muted-foreground">Select a keyframe on the timeline to edit its properties</p>
</CardContent>
</Card>
); );
} }
@@ -72,10 +59,7 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform }; const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
const setTransform = (key: keyof TransformValue, value: number) => { const setTransform = (key: keyof TransformValue, value: number) => {
onChange(keyframe.id, { onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } });
...props,
transform: { ...t, [key]: value },
});
}; };
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => { const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => {
@@ -85,96 +69,77 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
const hasBg = props.backgroundColor && props.backgroundColor !== 'none'; const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
return ( return (
<Card className="h-full overflow-auto"> <div className="space-y-5">
<CardHeader> <div className="flex items-center gap-2">
<CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Properties Properties
<span className="text-muted-foreground font-normal text-sm ml-2">{keyframe.offset}%</span> </span>
</CardTitle> <span className="text-[9px] text-primary/60 font-mono bg-primary/10 px-1.5 py-0.5 rounded">
</CardHeader> {keyframe.offset}%
<CardContent className="space-y-5"> </span>
</div>
{/* Transform */} {/* Transform */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Transform</p> <p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Transform</p>
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} /> <SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} /> <SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} /> <SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} /> <SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} /> <SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} /> <SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} /> <SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div> </div>
{/* Visual */} {/* Visual */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Visual</p> <p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Visual</p>
<SliderRow label="Opacity" value={props.opacity ?? 1} min={0} max={1} step={0.01} onChange={(v) => setProp('opacity', v)} />
<SliderRow {/* Background color */}
label="Opacity" <div>
value={props.opacity ?? 1} <div className="flex items-center justify-between mb-1.5">
min={0} max={1} step={0.01} <label className="text-[9px] text-muted-foreground/50 font-mono">Background Color</label>
onChange={(v) => setProp('opacity', v)} <button
/> onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className={cn(
{/* Background color */} 'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
<div className="space-y-1.5"> hasBg
<Label className="text-[10px] text-muted-foreground">Background Color</Label> ? 'border-primary/40 text-primary bg-primary/10'
<div className="flex items-center gap-2"> : 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
<Input )}
type="color" >
value={hasBg ? props.backgroundColor! : '#8b5cf6'} {hasBg ? 'On' : 'Off'}
onChange={(e) => setProp('backgroundColor', e.target.value)} </button>
disabled={!hasBg} </div>
className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')} <div className="flex gap-1.5">
/> <input
<Input type="color"
type="text" value={hasBg ? props.backgroundColor! : '#8b5cf6'}
value={hasBg ? props.backgroundColor! : ''} onChange={(e) => setProp('backgroundColor', e.target.value)}
onChange={(e) => setProp('backgroundColor', e.target.value)} disabled={!hasBg}
disabled={!hasBg} className={cn('w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5', !hasBg && 'opacity-30 cursor-not-allowed')}
placeholder="none" />
className="font-mono text-xs flex-1" <input
/> type="text"
<Button value={hasBg ? props.backgroundColor! : ''}
size="xs" onChange={(e) => setProp('backgroundColor', e.target.value)}
variant={hasBg ? 'default' : 'outline'} disabled={!hasBg}
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')} placeholder="none"
className="shrink-0" className="flex-1 bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 disabled:opacity-30"
> />
{hasBg ? 'On' : 'Off'}
</Button>
</div>
</div> </div>
<SliderRow
label="Border Radius"
unit="px"
value={props.borderRadius ?? 0}
min={0} max={200}
onChange={(v) => setProp('borderRadius', v)}
/>
</div> </div>
{/* Filters */} <SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />
<div className="space-y-3"> </div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filter</p>
<SliderRow
label="Blur"
unit="px"
value={props.blur ?? 0}
min={0} max={50}
onChange={(v) => setProp('blur', v)}
/>
<SliderRow
label="Brightness"
value={props.brightness ?? 1}
min={0} max={3} step={0.01}
onChange={(v) => setProp('brightness', v)}
/>
</div>
</CardContent> {/* Filters */}
</Card> <div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Filter</p>
<SliderRow label="Blur" unit="px" value={props.blur ?? 0} min={0} max={50} onChange={(v) => setProp('blur', v)} />
<SliderRow label="Brightness" value={props.brightness ?? 1} min={0} max={3} step={0.01} onChange={(v) => setProp('brightness', v)} />
</div>
</div>
); );
} }

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import type { Keyframe } from '@/types/animate'; import type { Keyframe } from '@/types/animate';
@@ -14,11 +12,18 @@ interface Props {
onAdd: (offset: number) => void; onAdd: (offset: number) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onMove: (id: string, newOffset: number) => void; onMove: (id: string, newOffset: number) => void;
embedded?: boolean; // when true, no glass card wrapper (use inside another card)
} }
const TICKS = [0, 25, 50, 75, 100]; const TICKS = [0, 25, 50, 75, 100];
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) { const iconBtn = (disabled?: boolean) =>
cn(
'w-6 h-6 flex items-center justify-center rounded-md glass border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all',
disabled && 'opacity-30 cursor-not-allowed pointer-events-none'
);
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
const getOffsetFromEvent = (clientX: number): number => { const getOffsetFromEvent = (clientX: number): number => {
@@ -29,7 +34,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
}; };
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Ignore clicks that land directly on a keyframe marker
if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return; if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
onAdd(getOffsetFromEvent(e.clientX)); onAdd(getOffsetFromEvent(e.clientX));
}; };
@@ -39,16 +43,11 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
onSelect(id); onSelect(id);
const el = e.currentTarget as HTMLElement; const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId); el.setPointerCapture(e.pointerId);
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
const handleMove = (me: PointerEvent) => {
onMove(id, getOffsetFromEvent(me.clientX));
};
const handleUp = () => { const handleUp = () => {
el.removeEventListener('pointermove', handleMove); el.removeEventListener('pointermove', handleMove);
el.removeEventListener('pointerup', handleUp); el.removeEventListener('pointerup', handleUp);
}; };
el.addEventListener('pointermove', handleMove); el.addEventListener('pointermove', handleMove);
el.addEventListener('pointerup', handleUp); el.addEventListener('pointerup', handleUp);
}; };
@@ -56,91 +55,91 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
const sorted = [...keyframes].sort((a, b) => a.offset - b.offset); const sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
const selectedKf = keyframes.find((k) => k.id === selectedId); const selectedKf = keyframes.find((k) => k.id === selectedId);
return ( const content = (
<Card> <div className="space-y-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> {/* Header */}
<CardTitle>Keyframes</CardTitle> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''} Keyframes
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
</span> </span>
<Button <span className="text-[9px] text-muted-foreground/40 font-mono">
size="icon-xs" {keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
variant="outline" </span>
onClick={() => onAdd(50)} </div>
title="Add keyframe at 50%" <div className="flex items-center gap-1">
> <button onClick={() => onAdd(50)} title="Add at 50%" className={iconBtn()}>
<Plus className="h-3 w-3" /> <Plus className="w-3 h-3" />
</Button> </button>
<Button <button
size="icon-xs"
variant="outline"
disabled={!selectedId || keyframes.length <= 2}
onClick={() => selectedId && onDelete(selectedId)} onClick={() => selectedId && onDelete(selectedId)}
title="Delete selected keyframe" disabled={!selectedId || keyframes.length <= 2}
title="Delete selected"
className={iconBtn(!selectedId || keyframes.length <= 2)}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="w-3 h-3" />
</Button> </button>
</div> </div>
</CardHeader> </div>
<CardContent>
{/* Track */}
<div
ref={trackRef}
className="relative h-16 bg-muted/30 rounded-lg border border-border cursor-crosshair select-none"
onClick={handleTrackClick}
>
{/* Center line */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border" />
{/* Tick marks */} {/* Track */}
{TICKS.map((tick) => ( <div
<div ref={trackRef}
key={tick} className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none"
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none" onClick={handleTrackClick}
style={{ left: `${tick}%` }} >
> <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
<div className="w-px h-2 bg-muted-foreground/30 mt-0" /> {TICKS.map((tick) => (
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span> <div
</div> key={tick}
))} className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none"
style={{ left: `${tick}%` }}
>
<div className="w-px h-2 bg-muted-foreground/20" />
<span className="text-[8px] text-muted-foreground/30 mt-auto mb-1 font-mono">{tick}%</span>
</div>
))}
{sorted.map((kf) => (
<button
key={kf.id}
data-keyframe-marker
className={cn(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rotate-45 rounded-sm transition-all duration-150 touch-none',
kf.id === selectedId
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
: 'bg-muted-foreground/40 hover:bg-primary/70'
)}
style={{ left: `${kf.offset}%` }}
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
onPointerDown={(e) => handlePointerDown(e, kf.id)}
title={`${kf.offset}% — drag to move`}
/>
))}
</div>
{/* Keyframe markers */} {/* Offset labels */}
{sorted.map((kf) => ( <div className="relative h-4">
<button {sorted.map((kf) => (
key={kf.id} <span
data-keyframe-marker key={kf.id}
className={cn( className={cn(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 rounded-sm transition-all duration-150 touch-none', 'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
kf.id === selectedId kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
? 'bg-primary shadow-lg shadow-primary/40 scale-125' )}
: 'bg-muted-foreground/60 hover:bg-primary/70' style={{ left: `${kf.offset}%` }}
)} >
style={{ left: `${kf.offset}%` }} {kf.offset}%
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }} </span>
onPointerDown={(e) => handlePointerDown(e, kf.id)} ))}
title={`${kf.offset}% — drag to move`} </div>
/> </div>
))} );
</div>
{/* Offset labels below */} if (embedded) return <div>{content}</div>;
<div className="relative h-5 mt-1">
{sorted.map((kf) => ( return (
<span <div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
key={kf.id} {content}
className={cn( </div>
'absolute -translate-x-1/2 text-[10px] transition-colors',
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground'
)}
style={{ left: `${kf.offset}%` }}
>
{kf.offset}%
</span>
))}
</div>
</CardContent>
</Card>
); );
} }

View File

@@ -1,24 +1,20 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils/cn';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets'; import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
import { buildKeyframesOnly } from '@/lib/animate/cssBuilder'; import { buildKeyframesOnly } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, AnimationPreset } from '@/types/animate'; import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate';
interface Props { interface Props {
onSelect: (config: AnimationConfig) => void; onSelect: (config: AnimationConfig) => void;
} }
function PresetCard({ preset, onSelect }: { function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) {
preset: AnimationPreset;
onSelect: () => void;
}) {
const styleRef = useRef<HTMLStyleElement | null>(null); const styleRef = useRef<HTMLStyleElement | null>(null);
const animName = `preview-${preset.id}`; const animName = `preview-${preset.id}`;
const thumbDuration = Math.min(preset.config.duration, 1200);
// Inject only the @keyframes block under a unique name — no .animated class rule
useEffect(() => { useEffect(() => {
const renamedConfig = { ...preset.config, name: animName }; const renamedConfig = { ...preset.config, name: animName };
if (!styleRef.current) { if (!styleRef.current) {
@@ -26,25 +22,18 @@ function PresetCard({ preset, onSelect }: {
document.head.appendChild(styleRef.current); document.head.appendChild(styleRef.current);
} }
styleRef.current.textContent = buildKeyframesOnly(renamedConfig); styleRef.current.textContent = buildKeyframesOnly(renamedConfig);
return () => { return () => { styleRef.current?.remove(); styleRef.current = null; };
styleRef.current?.remove();
styleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Cap thumbnail duration so fast presets loop nicely; slow ones cap at 1.2s
const thumbDuration = Math.min(preset.config.duration, 1200);
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border bg-card/50 transition-all duration-200 hover:border-primary/50 hover:bg-accent/30 hover:shadow-sm" className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 transition-all hover:border-primary/40 hover:bg-primary/8 group"
> >
{/* Mini preview — animation driven entirely by inline style, not .animated class */} <div className="w-full h-12 flex items-center justify-center rounded-lg bg-white/3 overflow-hidden">
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
<div <div
className="w-8 h-8 rounded-md bg-gradient-to-br from-violet-500 to-purple-600" className="w-7 h-7 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
style={{ style={{
animationName: animName, animationName: animName,
animationDuration: `${thumbDuration}ms`, animationDuration: `${thumbDuration}ms`,
@@ -55,7 +44,7 @@ function PresetCard({ preset, onSelect }: {
}} }}
/> />
</div> </div>
<span className="text-[11px] font-medium text-center leading-tight text-foreground/80"> <span className="text-[10px] font-mono text-center leading-tight text-foreground/60 group-hover:text-foreground/80 transition-colors">
{preset.name} {preset.name}
</span> </span>
</button> </button>
@@ -63,35 +52,32 @@ function PresetCard({ preset, onSelect }: {
} }
export function PresetLibrary({ onSelect }: Props) { export function PresetLibrary({ onSelect }: Props) {
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
return ( return (
<Card> <div className="space-y-3">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle>Presets</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span>
</CardHeader> <div className="flex glass rounded-lg p-0.5 gap-0.5">
<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) => ( {PRESET_CATEGORIES.map((cat) => (
<TabsContent key={cat} value={cat}> <button
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2"> key={cat}
{PRESETS.filter((p) => p.category === cat).map((preset) => ( onClick={() => setCategory(cat)}
<PresetCard className={cn(
key={preset.id} 'px-2 py-1 rounded-md text-[10px] font-mono transition-all',
preset={preset} category === cat ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
onSelect={() => onSelect(preset.config)} )}
/> >
))} {cat}
</div> </button>
</TabsContent>
))} ))}
</Tabs> </div>
</CardContent> </div>
</Card> <div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{PRESETS.filter((p) => p.category === category).map((preset) => (
<PresetCard key={preset.id} preset={preset} onSelect={() => onSelect(preset.config)} />
))}
</div>
</div>
); );
} }