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

@@ -1,8 +1,6 @@
'use client';
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 { cn } from '@/lib/utils/cn';
import type { Keyframe } from '@/types/animate';
@@ -14,11 +12,18 @@ interface Props {
onAdd: (offset: number) => void;
onDelete: (id: string) => 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];
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 getOffsetFromEvent = (clientX: number): number => {
@@ -29,7 +34,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
};
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;
onAdd(getOffsetFromEvent(e.clientX));
};
@@ -39,16 +43,11 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
onSelect(id);
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const handleMove = (me: PointerEvent) => {
onMove(id, getOffsetFromEvent(me.clientX));
};
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
const handleUp = () => {
el.removeEventListener('pointermove', handleMove);
el.removeEventListener('pointerup', handleUp);
};
el.addEventListener('pointermove', handleMove);
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 selectedKf = keyframes.find((k) => k.id === selectedId);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Keyframes</CardTitle>
const content = (
<div className="space-y-2">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''}
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Keyframes
</span>
<Button
size="icon-xs"
variant="outline"
onClick={() => onAdd(50)}
title="Add keyframe at 50%"
>
<Plus className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
disabled={!selectedId || keyframes.length <= 2}
<span className="text-[9px] text-muted-foreground/40 font-mono">
{keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => onAdd(50)} title="Add at 50%" className={iconBtn()}>
<Plus className="w-3 h-3" />
</button>
<button
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" />
</Button>
<Trash2 className="w-3 h-3" />
</button>
</div>
</CardHeader>
<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" />
</div>
{/* Tick marks */}
{TICKS.map((tick) => (
<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/30 mt-0" />
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span>
</div>
))}
{/* Track */}
<div
ref={trackRef}
className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none"
onClick={handleTrackClick}
>
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
{TICKS.map((tick) => (
<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 */}
{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-4 h-4 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/60 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>
{/* Offset labels */}
<div className="relative h-4">
{sorted.map((kf) => (
<span
key={kf.id}
className={cn(
'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
)}
style={{ left: `${kf.offset}%` }}
>
{kf.offset}%
</span>
))}
</div>
</div>
);
{/* Offset labels below */}
<div className="relative h-5 mt-1">
{sorted.map((kf) => (
<span
key={kf.id}
className={cn(
'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>
if (embedded) return <div>{content}</div>;
return (
<div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
{content}
</div>
);
}