Files
kit-ui/components/animate/AnimationPreview.tsx
Sebastian Krüger ea464ef797 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>
2026-03-01 08:48:35 +01:00

161 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate';
interface Props {
config: AnimationConfig;
element: PreviewElement;
onElementChange: (e: PreviewElement) => void;
}
type AnimState = 'playing' | 'paused' | 'ended';
const SPEEDS: { label: string; value: string }[] = [
{ label: '0.25×', value: '0.25' },
{ label: '0.5×', value: '0.5' },
{ label: '1×', value: '1' },
{ 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) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const [restartKey, setRestartKey] = useState(0);
const [animState, setAnimState] = useState<AnimState>('playing');
const [speed, setSpeed] = useState('1');
useEffect(() => {
if (!styleRef.current) {
styleRef.current = document.createElement('style');
styleRef.current.id = 'kit-animate-preview';
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildCSS(config);
setAnimState('playing');
setRestartKey((k) => k + 1);
}, [config]);
useEffect(() => {
return () => { styleRef.current?.remove(); };
}, []);
const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
const scaledDuration = Math.round(config.duration / Number(speed));
const isInfinite = config.iterationCount === 'infinite';
return (
<div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
{/* Header: speed pills */}
<div className="flex items-center justify-between shrink-0">
<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) => (
<button key={s.value} onClick={() => setSpeed(s.value)} className={pillCls(speed === s.value)}>
{s.label}
</button>
))}
</div>
</div>
{/* Canvas */}
<div
className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
backgroundImage: [
'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
'linear-gradient(var(--border) 1px, transparent 1px)',
'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}
>
<Play className="w-3 h-3" />
</button>
<button
onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'}
title="Pause"
className={actionBtn}
>
<Pause className="w-3 h-3" />
</button>
<button onClick={restart} title="Restart" className={actionBtn}>
<RotateCcw className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}