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,17 +1,7 @@
'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 { cn } from '@/lib/utils/cn';
import type { AnimationConfig } from '@/types/animate';
interface Props {
@@ -30,14 +20,38 @@ const EASINGS = [
{ 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) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value });
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 m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
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(',')})`);
};
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
set('name', val || 'myAnimation');
}}
className="font-mono text-xs"
<div className="space-y-4">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Settings
</span>
{/* Name */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
<input
type="text"
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
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>
<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 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Duration</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Delay</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
{/* Easing */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
<select
value={easingSelectValue}
onChange={(e) => {
const v = e.target.value;
set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
}}
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"
>
{EASINGS.map((e) => (
<option key={e.value} value={e.value} className="bg-[#1a1a2e]">
{e.label}
</option>
))}
</select>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
cubic-bezier(P1x, P1y, P2x, P2y)
</label>
<div className="grid grid-cols-4 gap-1.5">
{(['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>
<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="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>
)}
{/* Easing */}
<div className="space-y-1.5">
<Label className="text-xs">Easing</Label>
<Select
value={isCubic ? 'cubic-bezier' : config.easing}
onValueChange={(v) => {
if (v === 'cubic-bezier') {
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)');
} else {
set('easing', v);
}
}}
{/* Iterations */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
<div className="flex gap-1.5">
<input
type="number"
min={1}
max={999}
value={isInfinite ? '' : (config.iterationCount as number)}
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">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EASINGS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Infinity className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label>
<div className="grid grid-cols-4 gap-1.5">
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label} className="space-y-0.5">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
<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>
{/* Direction */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
<div className="flex gap-1">
{DIRECTIONS.map(({ value, label }) => (
<button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
{label}
</button>
))}
</div>
</div>
{/* Direction */}
<div className="space-y-1.5">
<Label className="text-xs">Direction</Label>
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="reverse">Reverse</SelectItem>
<SelectItem value="alternate">Alternate</SelectItem>
<SelectItem value="alternate-reverse">Alternate Reverse</SelectItem>
</SelectContent>
</Select>
{/* Fill Mode */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
<div className="flex gap-1">
{FILL_MODES.map(({ value, label }) => (
<button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
{label}
</button>
))}
</div>
{/* Fill Mode */}
<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>
</div>
</div>
);
}