From ea464ef79726c27bcb52577fbffd5e7050306606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 1 Mar 2026 08:48:35 +0100 Subject: [PATCH] refactor: align animate tool with Calculate/Media blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 { - const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, ''); - set('name', val || 'myAnimation'); - }} - className="font-mono text-xs" +
+ + + Settings + + + {/* Name */} +
+ + { + const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, ''); + set('name', val || 'myAnimation'); + }} + className={inputCls} + /> +
+ + {/* Duration + Delay */} +
+
+ + set('duration', Math.max(50, Number(e.target.value)))} + className={inputCls} />
+
+ + set('delay', Math.max(0, Number(e.target.value)))} + className={inputCls} + /> +
+
- {/* Duration + Delay */} -
-
- -
- set('duration', Math.max(50, Number(e.target.value)))} - - /> - ms -
-
-
- -
- set('delay', Math.max(0, Number(e.target.value)))} - - /> - ms -
+ {/* Easing */} +
+ + +
+ + {/* Cubic-bezier inputs */} + {isCubic && ( +
+ +
+ {(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => ( +
+ + 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" + /> +
+ ))}
+ )} - {/* Easing */} -
- - set('iterationCount', Math.max(1, Number(e.target.value)))} + placeholder="1" + className={cn(inputCls, 'flex-1', isInfinite && 'opacity-30')} + /> +
+
- {/* Cubic-bezier inputs */} - {isCubic && ( -
- -
- {(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => ( -
- - setCubic(i, Number(e.target.value))} - className="text-xs px-1.5" - /> -
- ))} -
-
- )} - - {/* Iteration */} -
- -
- set('iterationCount', Math.max(1, Number(e.target.value)))} - className="text-xs flex-1" - placeholder="1" - /> - -
+ {/* Direction */} +
+ +
+ {DIRECTIONS.map(({ value, label }) => ( + + ))}
+
- {/* Direction */} -
- - + {/* Fill Mode */} +
+ +
+ {FILL_MODES.map(({ value, label }) => ( + + ))}
- - {/* Fill Mode */} -
- - -
- - +
+
); } diff --git a/components/animate/ExportPanel.tsx b/components/animate/ExportPanel.tsx index 10e1b12..65aaf7b 100644 --- a/components/animate/ExportPanel.tsx +++ b/components/animate/ExportPanel.tsx @@ -1,11 +1,9 @@ 'use client'; -import { useMemo } 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 { useMemo, useState } from 'react'; import { Copy, Download } from 'lucide-react'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils/cn'; import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder'; import type { AnimationConfig } from '@/types/animate'; @@ -13,6 +11,11 @@ interface Props { 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 }) { const copy = () => { navigator.clipboard.writeText(code); @@ -31,49 +34,50 @@ function CodeBlock({ code, filename }: { code: string; filename: string }) { }; return ( -
-
-
+    
+
+
           {code}
         
-
- - +
+ +
); } export function ExportPanel({ config }: Props) { + const [tab, setTab] = useState('css'); const css = useMemo(() => buildCSS(config), [config]); const tailwind = useMemo(() => buildTailwindCSS(config), [config]); return ( - - - Export - - - - - Plain CSS - Tailwind v4 - - - - - - - - - - +
+
+ Export +
+ {(['css', 'tailwind'] as ExportTab[]).map((t) => ( + + ))} +
+
+ {tab === 'css' && } + {tab === 'tailwind' && } +
); } diff --git a/components/animate/KeyframeProperties.tsx b/components/animate/KeyframeProperties.tsx index 387d364..4c0d25b 100644 --- a/components/animate/KeyframeProperties.tsx +++ b/components/animate/KeyframeProperties.tsx @@ -1,10 +1,6 @@ '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 { Button } from '@/components/ui/button'; import { MousePointerClick } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; 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) { return (
-
- - onChange(v)} - /> +
+ + onChange(v)} />
- 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" />
); @@ -56,15 +46,12 @@ function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderR export function KeyframeProperties({ keyframe, onChange }: Props) { if (!keyframe) { return ( - - - Properties - - - -

Select a keyframe on the timeline to edit its properties

-
-
+
+ +

+ Select a keyframe on the timeline to edit its properties +

+
); } @@ -72,10 +59,7 @@ export function KeyframeProperties({ keyframe, onChange }: Props) { const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform }; const setTransform = (key: keyof TransformValue, value: number) => { - onChange(keyframe.id, { - ...props, - transform: { ...t, [key]: value }, - }); + onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } }); }; const setProp = (key: K, value: KeyframeProperties[K]) => { @@ -85,96 +69,77 @@ export function KeyframeProperties({ keyframe, onChange }: Props) { const hasBg = props.backgroundColor && props.backgroundColor !== 'none'; return ( - - - +
+
+ Properties - {keyframe.offset}% - - - + + + {keyframe.offset}% + +
- {/* Transform */} -
-

Transform

- setTransform('translateX', v)} /> - setTransform('translateY', v)} /> - setTransform('rotate', v)} /> - setTransform('scaleX', v)} /> - setTransform('scaleY', v)} /> - setTransform('skewX', v)} /> - setTransform('skewY', v)} /> -
+ {/* Transform */} +
+

Transform

+ setTransform('translateX', v)} /> + setTransform('translateY', v)} /> + setTransform('rotate', v)} /> + setTransform('scaleX', v)} /> + setTransform('scaleY', v)} /> + setTransform('skewX', v)} /> + setTransform('skewY', v)} /> +
- {/* Visual */} -
-

Visual

+ {/* Visual */} +
+

Visual

+ setProp('opacity', v)} /> - setProp('opacity', v)} - /> - - {/* Background color */} -
- -
- setProp('backgroundColor', e.target.value)} - disabled={!hasBg} - className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')} - /> - setProp('backgroundColor', e.target.value)} - disabled={!hasBg} - placeholder="none" - className="font-mono text-xs flex-1" - /> - -
+ {/* Background color */} +
+
+ + +
+
+ setProp('backgroundColor', e.target.value)} + 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')} + /> + setProp('backgroundColor', e.target.value)} + disabled={!hasBg} + placeholder="none" + 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" + />
- - setProp('borderRadius', v)} - />
- {/* Filters */} -
-

Filter

- setProp('blur', v)} - /> - setProp('brightness', v)} - /> -
+ setProp('borderRadius', v)} /> +
- - + {/* Filters */} +
+

Filter

+ setProp('blur', v)} /> + setProp('brightness', v)} /> +
+
); } diff --git a/components/animate/KeyframeTimeline.tsx b/components/animate/KeyframeTimeline.tsx index d5a413f..d38840f 100644 --- a/components/animate/KeyframeTimeline.tsx +++ b/components/animate/KeyframeTimeline.tsx @@ -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(null); const getOffsetFromEvent = (clientX: number): number => { @@ -29,7 +34,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel }; const handleTrackClick = (e: React.MouseEvent) => { - // 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 ( - - - Keyframes + const content = ( +
+ {/* Header */} +
- - {keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''} - {selectedKf ? ` · selected: ${selectedKf.offset}%` : ''} + + Keyframes - -
+
+ + + +
- - - {/* Track */} -
- {/* Center line */} -
+
- {/* Tick marks */} - {TICKS.map((tick) => ( -
-
- {tick}% -
- ))} + {/* Track */} +
+
+ {TICKS.map((tick) => ( +
+
+ {tick}% +
+ ))} + {sorted.map((kf) => ( +
- {/* Keyframe markers */} - {sorted.map((kf) => ( -
+ {/* Offset labels */} +
+ {sorted.map((kf) => ( + + {kf.offset}% + + ))} +
+
+ ); - {/* Offset labels below */} -
- {sorted.map((kf) => ( - - {kf.offset}% - - ))} -
- - + if (embedded) return
{content}
; + + return ( +
+ {content} +
); } diff --git a/components/animate/PresetLibrary.tsx b/components/animate/PresetLibrary.tsx index bcba38d..07abccd 100644 --- a/components/animate/PresetLibrary.tsx +++ b/components/animate/PresetLibrary.tsx @@ -1,24 +1,20 @@ '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 { useEffect, useRef, useState } from 'react'; +import { cn } from '@/lib/utils/cn'; import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets'; import { buildKeyframesOnly } from '@/lib/animate/cssBuilder'; -import type { AnimationConfig, AnimationPreset } from '@/types/animate'; +import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate'; interface Props { onSelect: (config: AnimationConfig) => void; } -function PresetCard({ preset, onSelect }: { - preset: AnimationPreset; - onSelect: () => void; -}) { +function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) { const styleRef = useRef(null); 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(() => { const renamedConfig = { ...preset.config, name: animName }; if (!styleRef.current) { @@ -26,25 +22,18 @@ function PresetCard({ preset, onSelect }: { document.head.appendChild(styleRef.current); } styleRef.current.textContent = buildKeyframesOnly(renamedConfig); - return () => { - styleRef.current?.remove(); - styleRef.current = null; - }; + return () => { styleRef.current?.remove(); styleRef.current = null; }; // 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 ( @@ -63,35 +52,32 @@ function PresetCard({ preset, onSelect }: { } export function PresetLibrary({ onSelect }: Props) { + const [category, setCategory] = useState(PRESET_CATEGORIES[0]); + return ( - - - Presets - - - - - {PRESET_CATEGORIES.map((cat) => ( - - {cat} - - ))} - +
+
+ Presets +
{PRESET_CATEGORIES.map((cat) => ( - -
- {PRESETS.filter((p) => p.category === cat).map((preset) => ( - onSelect(preset.config)} - /> - ))} -
-
+ ))} - - - +
+
+
+ {PRESETS.filter((p) => p.category === category).map((preset) => ( + onSelect(preset.config)} /> + ))} +
+
); }