849 lines
32 KiB
TypeScript
849 lines
32 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import {
|
||
ScanLine,
|
||
Search,
|
||
Globe,
|
||
MousePointer2,
|
||
BarChart3,
|
||
Puzzle,
|
||
Clapperboard,
|
||
MessageSquare,
|
||
Focus,
|
||
Play,
|
||
Maximize2,
|
||
} from 'lucide-react';
|
||
import { useI18n } from '@/lib/hooks/use-i18n';
|
||
import { cn } from '@/lib/utils';
|
||
import type { SceneOutline } from '@/lib/types/generation';
|
||
|
||
// Step-specific visualizers
|
||
export function StepVisualizer({
|
||
stepId,
|
||
outlines,
|
||
webSearchSources,
|
||
onExpandOutline,
|
||
}: {
|
||
stepId: string;
|
||
outlines?: SceneOutline[] | null;
|
||
webSearchSources?: Array<{ title: string; url: string }>;
|
||
onExpandOutline?: () => void;
|
||
}) {
|
||
switch (stepId) {
|
||
case 'pdf-analysis':
|
||
return <PdfScanVisualizer />;
|
||
case 'web-search':
|
||
return <WebSearchVisualizer sources={webSearchSources || []} />;
|
||
case 'outline':
|
||
return <StreamingOutlineVisualizer outlines={outlines || []} onExpand={onExpandOutline} />;
|
||
case 'agent-generation':
|
||
return <AgentGenerationVisualizer />;
|
||
case 'slide-content':
|
||
return <ContentVisualizer />;
|
||
case 'actions':
|
||
return <ActionsVisualizer />;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// PDF: Document with scanning laser line
|
||
function PdfScanVisualizer() {
|
||
return (
|
||
<div className="size-32 relative flex items-center justify-center">
|
||
<motion.div
|
||
className="absolute inset-2 bg-cyan-500/5 rounded-2xl blur-lg"
|
||
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
||
transition={{ duration: 2, repeat: Infinity }}
|
||
/>
|
||
<div className="w-20 h-28 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shadow-xl relative overflow-hidden">
|
||
<div className="p-3 space-y-2 mt-1">
|
||
{[80, 60, 90, 45, 70].map((w, i) => (
|
||
<motion.div
|
||
key={i}
|
||
className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded"
|
||
style={{ width: `${w}%` }}
|
||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||
transition={{ duration: 2, repeat: Infinity, delay: i * 0.2 }}
|
||
/>
|
||
))}
|
||
</div>
|
||
{/* Scanning laser */}
|
||
<motion.div
|
||
className="absolute inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-cyan-400 to-transparent shadow-[0_0_12px_rgba(34,211,238,0.6)]"
|
||
animate={{ top: ['5%', '90%', '5%'] }}
|
||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
||
/>
|
||
</div>
|
||
<motion.div
|
||
className="absolute -top-1 -right-1"
|
||
animate={{ rotate: [0, 10, -10, 0] }}
|
||
transition={{ duration: 3, repeat: Infinity }}
|
||
>
|
||
<ScanLine className="size-6 text-cyan-500/70" />
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Web Search: Miniature search engine results page with animated query + result rows
|
||
function WebSearchVisualizer({ sources }: { sources: Array<{ title: string; url: string }> }) {
|
||
const [activeResult, setActiveResult] = useState(0);
|
||
|
||
// Cycle through result highlight when we have sources
|
||
useEffect(() => {
|
||
if (sources.length === 0) return;
|
||
const timer = setInterval(() => {
|
||
setActiveResult((prev) => (prev + 1) % Math.min(sources.length, 4));
|
||
}, 1400);
|
||
return () => clearInterval(timer);
|
||
}, [sources.length]);
|
||
|
||
// Placeholder results for skeleton state
|
||
const skeletonResults = [
|
||
{ titleW: 70, urlW: 45, snippetW: [90, 60] },
|
||
{ titleW: 55, urlW: 50, snippetW: [80, 75] },
|
||
{ titleW: 65, urlW: 40, snippetW: [85, 50] },
|
||
{ titleW: 50, urlW: 55, snippetW: [70, 65] },
|
||
];
|
||
|
||
const ROW_H = 38;
|
||
|
||
return (
|
||
<div className="size-56 relative flex items-center justify-center">
|
||
{/* Background glow */}
|
||
<motion.div
|
||
className="absolute inset-0 blur-3xl rounded-full bg-teal-500/8"
|
||
animate={{ scale: [1, 1.15, 1], opacity: [0.3, 0.5, 0.3] }}
|
||
transition={{ duration: 3.5, repeat: Infinity }}
|
||
/>
|
||
|
||
{/* Search results card */}
|
||
<div className="w-44 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl overflow-hidden relative">
|
||
{/* Search bar header */}
|
||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700 flex items-center gap-2">
|
||
<Search className="size-3 text-teal-500 shrink-0" />
|
||
<div className="flex-1 h-4 bg-slate-50 dark:bg-slate-700/50 rounded-full overflow-hidden flex items-center px-2">
|
||
<motion.div
|
||
className="h-1.5 bg-teal-500/25 rounded-full"
|
||
initial={{ width: 0 }}
|
||
animate={{ width: '70%' }}
|
||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Results list */}
|
||
<div className="p-2 space-y-0.5 relative">
|
||
{/* Sliding highlight */}
|
||
{sources.length > 0 && (
|
||
<motion.div
|
||
className="absolute left-2 right-2 rounded-lg bg-teal-500/[0.06] dark:bg-teal-400/[0.08]"
|
||
style={{ height: ROW_H - 6 }}
|
||
animate={{ y: activeResult * ROW_H }}
|
||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||
/>
|
||
)}
|
||
|
||
{sources.length === 0
|
||
? // Skeleton: pulsing result placeholders
|
||
skeletonResults.map((item, i) => (
|
||
<motion.div
|
||
key={i}
|
||
className="px-2 py-1.5 space-y-1"
|
||
animate={{ opacity: [0.3, 0.7, 0.3] }}
|
||
transition={{
|
||
duration: 1.4,
|
||
repeat: Infinity,
|
||
delay: i * 0.15,
|
||
}}
|
||
>
|
||
<div
|
||
className="h-1.5 bg-teal-200/40 dark:bg-teal-800/30 rounded"
|
||
style={{ width: `${item.titleW}%` }}
|
||
/>
|
||
<div
|
||
className="h-1 bg-slate-100 dark:bg-slate-700 rounded"
|
||
style={{ width: `${item.urlW}%` }}
|
||
/>
|
||
<div className="flex gap-1">
|
||
{item.snippetW.map((w, j) => (
|
||
<div
|
||
key={j}
|
||
className="h-1 bg-slate-100 dark:bg-slate-700 rounded"
|
||
style={{ width: `${w * 0.5}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</motion.div>
|
||
))
|
||
: // Live results
|
||
sources.slice(0, 4).map((source, i) => {
|
||
const isActive = i === activeResult;
|
||
return (
|
||
<motion.div
|
||
key={source.url}
|
||
initial={{ opacity: 0, x: -10 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: i * 0.08, duration: 0.25 }}
|
||
className="relative px-2 py-1.5 space-y-0.5"
|
||
>
|
||
<div
|
||
className={cn(
|
||
'text-[8px] font-semibold truncate transition-colors duration-300 leading-tight',
|
||
isActive
|
||
? 'text-teal-600 dark:text-teal-400'
|
||
: 'text-slate-600 dark:text-slate-400',
|
||
)}
|
||
>
|
||
{source.title}
|
||
</div>
|
||
<div className="text-[6px] text-teal-500/50 truncate leading-tight">
|
||
{source.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 32)}
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<div className="h-0.5 flex-1 bg-slate-100 dark:bg-slate-700 rounded-full" />
|
||
<div className="h-0.5 w-1/3 bg-slate-100 dark:bg-slate-700 rounded-full" />
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Scanning beam */}
|
||
<motion.div
|
||
className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/20 dark:via-white/5 to-transparent -skew-x-12 pointer-events-none"
|
||
initial={{ left: '-150%' }}
|
||
animate={{ left: '200%' }}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: Infinity,
|
||
repeatDelay: 2,
|
||
ease: 'linear',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Source count badge */}
|
||
{sources.length > 0 && (
|
||
<motion.div
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
|
||
className="absolute -top-2 -right-2 h-6 px-2 rounded-full bg-teal-500 text-white text-[10px] font-bold flex items-center justify-center shadow-lg shadow-teal-500/25 z-20 gap-0.5"
|
||
>
|
||
<Globe className="size-2.5" />
|
||
{sources.length}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Outline: Streams real outline data as it arrives from SSE
|
||
function StreamingOutlineVisualizer({
|
||
outlines,
|
||
onExpand,
|
||
}: {
|
||
outlines: SceneOutline[];
|
||
onExpand?: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const isInteractive = !!onExpand;
|
||
|
||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (!isInteractive) return;
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
onExpand?.();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
layoutId="outline-review-surface"
|
||
transition={{ type: 'spring', stiffness: 220, damping: 28 }}
|
||
// Slight tilt to suggest a tossed-down sticky note; straightens on hover.
|
||
// Layout-shared morph into the editor surface explicitly targets rotate: 0,
|
||
// so the rotation is interpolated as the surface grows.
|
||
initial={isInteractive ? { rotate: -3 } : false}
|
||
animate={isInteractive ? { rotate: -3 } : { rotate: 0 }}
|
||
whileHover={isInteractive ? { y: -3, scale: 1.04, rotate: 0 } : undefined}
|
||
whileTap={isInteractive ? { scale: 0.97, rotate: 0 } : undefined}
|
||
role={isInteractive ? 'button' : undefined}
|
||
tabIndex={isInteractive ? 0 : undefined}
|
||
aria-label={isInteractive ? t('generation.outlineExpandHint') : undefined}
|
||
onClick={onExpand}
|
||
onKeyDown={handleKeyDown}
|
||
style={{ transformOrigin: 'center center' }}
|
||
className={cn(
|
||
'group/outline-card relative h-52 w-44 overflow-hidden rounded-2xl border p-3 text-left',
|
||
'bg-white/90 shadow-xl shadow-slate-900/10 backdrop-blur',
|
||
'dark:bg-slate-900/80',
|
||
isInteractive
|
||
? 'cursor-pointer border-blue-400/40 shadow-blue-500/10 transition-shadow hover:border-blue-500/60 hover:shadow-2xl hover:shadow-blue-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:border-blue-400/30 dark:hover:border-blue-400/50'
|
||
: 'border-slate-200/70 dark:border-white/10',
|
||
)}
|
||
>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div className="h-1.5 w-12 rounded-full bg-slate-200 dark:bg-slate-700" />
|
||
<motion.div
|
||
className="size-2 rounded-full bg-blue-500"
|
||
animate={{ opacity: [0.3, 1, 0.3], scale: [0.9, 1.15, 0.9] }}
|
||
transition={{ repeat: Infinity, duration: 1.1 }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative h-[168px] overflow-hidden pl-4">
|
||
{/* Spine sits at the dot's horizontal center: pl-4 (16px) - dot offset (-15px) + dot half (4px) = 5px */}
|
||
<div className="absolute bottom-0 left-[5px] top-0 w-px bg-slate-200 dark:bg-slate-700" />
|
||
<AnimatePresence initial={false}>
|
||
{outlines.length === 0
|
||
? [58, 78, 48, 68].map((w, i) => (
|
||
<motion.div
|
||
key={i}
|
||
className="relative mb-3"
|
||
animate={{ opacity: [0.35, 0.75, 0.35] }}
|
||
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.16 }}
|
||
>
|
||
<div className="absolute -left-[15px] top-0.5 size-2 rounded-full border border-blue-300 bg-white dark:bg-slate-900" />
|
||
<div
|
||
className="h-2 rounded-full bg-slate-100 dark:bg-slate-800"
|
||
style={{ width: `${w}%` }}
|
||
/>
|
||
<div className="mt-1 h-1 w-2/3 rounded-full bg-slate-100 dark:bg-slate-800" />
|
||
</motion.div>
|
||
))
|
||
: outlines.slice(0, 4).map((outline, i) => (
|
||
<motion.div
|
||
key={outline.id}
|
||
layout
|
||
initial={{ opacity: 0, x: -10, scale: 0.96 }}
|
||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||
exit={{ opacity: 0, x: -10, scale: 0.96 }}
|
||
transition={{ type: 'spring', stiffness: 420, damping: 34 }}
|
||
className="relative mb-3"
|
||
>
|
||
<div className="absolute -left-[15px] top-0.5 size-2 rounded-full border border-blue-300 bg-white shadow-sm shadow-blue-500/20 dark:bg-slate-900" />
|
||
<div className="truncate text-[9px] font-semibold leading-tight text-slate-700 dark:text-slate-200">
|
||
{i + 1}. {outline.title}
|
||
</div>
|
||
<div className="mt-1 truncate text-[7px] leading-tight text-slate-400 dark:text-slate-500">
|
||
{outline.description || outline.keyPoints?.[0] || '...'}
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
|
||
{outlines.length > 4 && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-[8px] font-medium text-blue-500/70"
|
||
>
|
||
+{outlines.length - 4}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
|
||
<motion.div
|
||
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-10 bg-gradient-to-t from-white via-white/80 to-transparent dark:from-slate-900 dark:via-slate-900/80"
|
||
aria-hidden
|
||
/>
|
||
|
||
{/* Expand affordance — always visible to telegraph clickability */}
|
||
{isInteractive && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-20 flex items-center justify-center px-2">
|
||
<motion.span
|
||
className="inline-flex items-center gap-1 rounded-full bg-blue-500 px-2.5 py-1 text-[10px] font-semibold tracking-wide text-white shadow-lg shadow-blue-500/50 backdrop-blur"
|
||
animate={{ y: [0, -1.5, 0] }}
|
||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||
>
|
||
<Maximize2 className="size-3" />
|
||
{t('generation.outlineExpandHint')}
|
||
</motion.span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Subtle expand icon in top-right corner — pulses gently */}
|
||
{isInteractive && (
|
||
<motion.div
|
||
className="absolute right-2 top-2 z-10 flex size-5 items-center justify-center rounded-md text-blue-500/80 group-hover/outline-card:text-blue-600 dark:text-blue-400/80 dark:group-hover/outline-card:text-blue-300"
|
||
animate={{ opacity: [0.6, 1, 0.6] }}
|
||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||
aria-hidden
|
||
>
|
||
<Maximize2 className="size-3" />
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
// Content: Cycles through distinct representations of Slides, Quiz, PBL, Interactive
|
||
function AgentGenerationVisualizer() {
|
||
return (
|
||
<div className="w-60 h-40 mx-auto flex items-center justify-center">
|
||
<div className="flex gap-3">
|
||
{[0, 1, 2].map((i) => (
|
||
<motion.div
|
||
key={i}
|
||
className="w-14 h-20 rounded-lg bg-gradient-to-br from-purple-400 to-blue-500 dark:from-purple-600 dark:to-blue-700 shadow-lg"
|
||
animate={{ y: [0, -8, 0], rotateZ: [0, 3, -3, 0] }}
|
||
transition={{
|
||
duration: 1.5,
|
||
repeat: Infinity,
|
||
delay: i * 0.3,
|
||
ease: 'easeInOut',
|
||
}}
|
||
>
|
||
<div className="w-full h-full flex items-center justify-center text-white/80 text-lg font-bold">
|
||
?
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContentVisualizer() {
|
||
const [index, setIndex] = useState(0);
|
||
|
||
// 0: Slide (Blue)
|
||
// 1: Quiz (Purple)
|
||
// 2: PBL (Amber)
|
||
// 3: Interactive (Emerald)
|
||
const totalTypes = 4;
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
setIndex((prev) => (prev + 1) % totalTypes);
|
||
}, 3200);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
const variants = {
|
||
enter: { x: 50, opacity: 0, scale: 0.9, rotateY: -15 },
|
||
center: { x: 0, opacity: 1, scale: 1, rotateY: 0 },
|
||
exit: { x: -50, opacity: 0, scale: 0.9, rotateY: 15 },
|
||
};
|
||
|
||
const getTheme = (idx: number) => {
|
||
switch (idx) {
|
||
case 0:
|
||
return {
|
||
color: 'blue',
|
||
label: 'SLIDE',
|
||
badge:
|
||
'bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-800',
|
||
};
|
||
case 1:
|
||
return {
|
||
color: 'purple',
|
||
label: 'QUIZ',
|
||
badge:
|
||
'bg-purple-100 text-purple-600 border-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:border-purple-800',
|
||
};
|
||
case 2:
|
||
return {
|
||
color: 'amber',
|
||
label: 'PBL',
|
||
badge:
|
||
'bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:border-amber-800',
|
||
};
|
||
case 3:
|
||
return {
|
||
color: 'emerald',
|
||
label: 'WEB',
|
||
badge:
|
||
'bg-emerald-100 text-emerald-600 border-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300 dark:border-emerald-800',
|
||
};
|
||
default:
|
||
return { color: 'blue', label: '', badge: '' };
|
||
}
|
||
};
|
||
|
||
const theme = getTheme(index);
|
||
|
||
return (
|
||
<div className="size-56 relative flex items-center justify-center perspective-[800px]">
|
||
{/* Background glow based on current theme */}
|
||
<motion.div
|
||
key={`glow-${index}`}
|
||
className={cn(
|
||
'absolute inset-0 blur-3xl rounded-full transition-colors duration-1000',
|
||
theme.color === 'blue' && 'bg-blue-500/10',
|
||
theme.color === 'purple' && 'bg-purple-500/10',
|
||
theme.color === 'amber' && 'bg-amber-500/10',
|
||
theme.color === 'emerald' && 'bg-emerald-500/10',
|
||
)}
|
||
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.6, 0.3] }}
|
||
transition={{ duration: 4, repeat: Infinity }}
|
||
/>
|
||
|
||
{/* Subtle orbiting rings (pushed back, slower) */}
|
||
{[0, 1].map((i) => (
|
||
<motion.div
|
||
key={i}
|
||
className={cn(
|
||
'absolute border rounded-full transition-colors duration-1000',
|
||
theme.color === 'blue' && 'border-blue-500/10',
|
||
theme.color === 'purple' && 'border-purple-500/10',
|
||
theme.color === 'amber' && 'border-amber-500/10',
|
||
theme.color === 'emerald' && 'border-emerald-500/10',
|
||
)}
|
||
style={{
|
||
width: 180 + i * 50,
|
||
height: 180 + i * 50,
|
||
borderStyle: 'dashed',
|
||
}}
|
||
animate={{ rotate: 360 }}
|
||
transition={{
|
||
duration: 40 + i * 15,
|
||
ease: 'linear',
|
||
repeat: Infinity,
|
||
delay: i * -5,
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{/* Main Content Container */}
|
||
<div className="w-40 h-28 relative">
|
||
<AnimatePresence mode="popLayout">
|
||
<motion.div
|
||
key={index}
|
||
variants={variants}
|
||
initial="enter"
|
||
animate="center"
|
||
exit="exit"
|
||
transition={{ type: 'spring', stiffness: 80, damping: 16 }}
|
||
className={cn(
|
||
'absolute inset-0 bg-white dark:bg-slate-800 rounded-xl border shadow-xl overflow-hidden flex flex-col p-3 origin-center',
|
||
theme.color === 'blue' && 'border-blue-200 dark:border-blue-900/30',
|
||
theme.color === 'purple' && 'border-purple-200 dark:border-purple-900/30',
|
||
theme.color === 'amber' && 'border-amber-200 dark:border-amber-900/30',
|
||
theme.color === 'emerald' && 'border-emerald-200 dark:border-emerald-900/30',
|
||
)}
|
||
>
|
||
{/* Consistent Badge - Now outside content logic */}
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ delay: 0.1 }}
|
||
className={cn(
|
||
'absolute top-1.5 right-1.5 z-20 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-wider border backdrop-blur-md shadow-sm',
|
||
theme.badge,
|
||
)}
|
||
>
|
||
{theme.label}
|
||
</motion.div>
|
||
|
||
{/* --- SLIDE TYPE --- */}
|
||
{index === 0 && (
|
||
<div className="flex flex-col h-full pt-1">
|
||
<motion.div
|
||
initial={{ width: '0%' }}
|
||
animate={{ width: '55%' }}
|
||
transition={{ delay: 0.2 }}
|
||
className="h-2 bg-blue-500/20 rounded-full mb-3 shrink-0"
|
||
/>
|
||
<div className="flex gap-2 flex-1">
|
||
<div className="flex-1 space-y-2">
|
||
{[0.8, 0.9, 0.6, 0.7].map((w, i) => (
|
||
<motion.div
|
||
key={i}
|
||
initial={{ width: 0 }}
|
||
animate={{ width: `${w * 100}%` }}
|
||
transition={{ delay: 0.3 + i * 0.1 }}
|
||
className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full"
|
||
/>
|
||
))}
|
||
</div>
|
||
<motion.div
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.4 }}
|
||
className="w-12 h-12 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center shrink-0"
|
||
>
|
||
<BarChart3 className="size-6 text-blue-500/60" />
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- QUIZ TYPE --- */}
|
||
{index === 1 && (
|
||
<div className="flex flex-col h-full justify-center space-y-2 pt-2">
|
||
<motion.div
|
||
initial={{ y: -5, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ delay: 0.2 }}
|
||
className="flex justify-center mb-1"
|
||
>
|
||
<div className="h-2 w-3/4 bg-purple-500/20 rounded-full" />
|
||
</motion.div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{[0, 1, 2, 3].map((i) => (
|
||
<motion.div
|
||
key={i}
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.3 + i * 0.1 }}
|
||
className={cn(
|
||
'h-6 rounded border flex items-center px-2',
|
||
i === 1
|
||
? 'bg-purple-500 text-white border-purple-500'
|
||
: 'bg-slate-50 dark:bg-slate-700/50 border-slate-100 dark:border-slate-700',
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'size-1.5 rounded-full mr-2',
|
||
i === 1 ? 'bg-white' : 'bg-slate-300',
|
||
)}
|
||
/>
|
||
<div
|
||
className={cn(
|
||
'h-1 w-8 rounded-full',
|
||
i === 1 ? 'bg-white/50' : 'bg-slate-200 dark:bg-slate-600',
|
||
)}
|
||
/>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- PBL TYPE --- */}
|
||
{index === 2 && (
|
||
<div className="flex flex-col h-full pt-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Puzzle className="size-3 text-amber-500 shrink-0" />
|
||
<motion.div
|
||
initial={{ width: 0 }}
|
||
animate={{ width: '40%' }}
|
||
className="h-2 bg-amber-500/20 rounded-full"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 flex gap-2 overflow-hidden">
|
||
{[0, 1, 2].map((col) => (
|
||
<motion.div
|
||
key={col}
|
||
initial={{ y: 20, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ delay: 0.2 + col * 0.15 }}
|
||
className="flex-1 bg-slate-50 dark:bg-slate-700/30 rounded flex flex-col gap-1 p-1"
|
||
>
|
||
<div className="h-1 w-6 bg-slate-200 dark:bg-slate-600 rounded mb-1" />
|
||
{[0, 1].map((card) => (
|
||
<div
|
||
key={card}
|
||
className="h-3 w-full bg-white dark:bg-slate-600 rounded border border-slate-100 dark:border-slate-500 shadow-sm"
|
||
/>
|
||
))}
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- INTERACTIVE TYPE --- */}
|
||
{index === 3 && (
|
||
<div className="flex flex-col h-full relative pt-1">
|
||
{/* Browser Chrome - Padded right to avoid badge */}
|
||
<div className="flex items-center gap-1 mb-2 border-b border-slate-100 dark:border-slate-700 pb-1 pr-10">
|
||
<div className="flex gap-0.5">
|
||
<div className="size-1.5 rounded-full bg-red-400" />
|
||
<div className="size-1.5 rounded-full bg-amber-400" />
|
||
<div className="size-1.5 rounded-full bg-green-400" />
|
||
</div>
|
||
<div className="h-1.5 flex-1 bg-slate-100 dark:bg-slate-700 rounded-full ml-1" />
|
||
</div>
|
||
|
||
<div className="flex-1 flex gap-2 relative">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.3 }}
|
||
className="w-1/3 bg-slate-50 dark:bg-slate-700/30 rounded p-1 space-y-1"
|
||
>
|
||
{[1, 2, 3].map((i) => (
|
||
<div
|
||
key={i}
|
||
className="h-1 w-full bg-slate-200 dark:bg-slate-600 rounded-full"
|
||
/>
|
||
))}
|
||
</motion.div>
|
||
<div className="flex-1 bg-emerald-50 dark:bg-emerald-900/10 rounded border border-emerald-100 dark:border-emerald-900/30 relative overflow-hidden flex items-center justify-center">
|
||
<Globe className="size-8 text-emerald-200 dark:text-emerald-800" />
|
||
<motion.div
|
||
className="absolute"
|
||
animate={{ x: [20, -10, 15, 0], y: [10, -15, 5, 0] }}
|
||
transition={{ duration: 3, ease: 'easeInOut' }}
|
||
>
|
||
<MousePointer2 className="size-3 text-emerald-600 fill-emerald-600" />
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scanning beam (shared) */}
|
||
<motion.div
|
||
className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/30 dark:via-white/10 to-transparent -skew-x-12 pointer-events-none"
|
||
initial={{ left: '-150%' }}
|
||
animate={{ left: '200%' }}
|
||
transition={{
|
||
duration: 1.5,
|
||
repeat: Infinity,
|
||
repeatDelay: 1,
|
||
ease: 'linear',
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Actions: Timeline of speech, spotlight, and interactions being orchestrated
|
||
function ActionsVisualizer() {
|
||
const [activeIdx, setActiveIdx] = useState(0);
|
||
|
||
const actionItems = [
|
||
{
|
||
icon: MessageSquare,
|
||
label: 'Speech',
|
||
color: 'text-violet-500',
|
||
activeBg: 'bg-violet-500/10',
|
||
activeBorder: 'border-violet-200 dark:border-violet-800',
|
||
},
|
||
{
|
||
icon: Focus,
|
||
label: 'Spotlight',
|
||
color: 'text-amber-500',
|
||
activeBg: 'bg-amber-500/10',
|
||
activeBorder: 'border-amber-200 dark:border-amber-800',
|
||
},
|
||
{
|
||
icon: MessageSquare,
|
||
label: 'Speech',
|
||
color: 'text-violet-500',
|
||
activeBg: 'bg-violet-500/10',
|
||
activeBorder: 'border-violet-200 dark:border-violet-800',
|
||
},
|
||
{
|
||
icon: Play,
|
||
label: 'Interact',
|
||
color: 'text-emerald-500',
|
||
activeBg: 'bg-emerald-500/10',
|
||
activeBorder: 'border-emerald-200 dark:border-emerald-800',
|
||
},
|
||
{
|
||
icon: MessageSquare,
|
||
label: 'Speech',
|
||
color: 'text-violet-500',
|
||
activeBg: 'bg-violet-500/10',
|
||
activeBorder: 'border-violet-200 dark:border-violet-800',
|
||
},
|
||
];
|
||
|
||
// Row height (py-1.5 = 6px×2 padding + icon ~16px) + gap 6px ≈ 34px per row
|
||
const ROW_H = 34;
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
setActiveIdx((prev) => (prev + 1) % actionItems.length);
|
||
}, 1600);
|
||
return () => clearInterval(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
return (
|
||
<div className="size-56 relative flex items-center justify-center">
|
||
{/* Background pulse */}
|
||
<motion.div
|
||
className="absolute inset-0 blur-3xl rounded-full bg-violet-500/8"
|
||
animate={{ scale: [1, 1.15, 1], opacity: [0.3, 0.5, 0.3] }}
|
||
transition={{ duration: 3.5, repeat: Infinity }}
|
||
/>
|
||
|
||
{/* Timeline card */}
|
||
<div className="w-44 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl overflow-hidden relative">
|
||
{/* Header */}
|
||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700 flex items-center gap-2">
|
||
<Clapperboard className="size-3 text-violet-500" />
|
||
<motion.div
|
||
initial={{ width: 0 }}
|
||
animate={{ width: '50%' }}
|
||
transition={{ delay: 0.2 }}
|
||
className="h-1.5 bg-violet-500/20 rounded-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Action items */}
|
||
<div className="p-2 space-y-1.5 relative">
|
||
{/* Sliding highlight — absolute, animates via y transform, no layout impact */}
|
||
<motion.div
|
||
className="absolute left-2 right-2 rounded-lg bg-violet-500/[0.06] dark:bg-violet-400/[0.08]"
|
||
style={{ height: ROW_H - 6 }}
|
||
animate={{ y: activeIdx * ROW_H }}
|
||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||
/>
|
||
|
||
{actionItems.map((item, i) => {
|
||
const Icon = item.icon;
|
||
const isActive = i === activeIdx;
|
||
const isPast = i < activeIdx;
|
||
return (
|
||
<motion.div
|
||
key={i}
|
||
initial={{ opacity: 0, x: -12 }}
|
||
animate={{ opacity: isPast ? 0.4 : 1, x: 0 }}
|
||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||
className="relative flex items-center gap-2 px-2 py-1.5 rounded-lg"
|
||
>
|
||
<div
|
||
className={cn(
|
||
'size-4 rounded flex items-center justify-center shrink-0 transition-colors duration-300',
|
||
isActive ? item.color : 'text-slate-300 dark:text-slate-600',
|
||
)}
|
||
>
|
||
<Icon className="size-3" />
|
||
</div>
|
||
<div className="flex-1 flex items-center gap-1.5">
|
||
<span
|
||
className={cn(
|
||
'text-[8px] font-semibold uppercase tracking-wider transition-colors duration-300',
|
||
isActive ? item.color : 'text-slate-400 dark:text-slate-500',
|
||
)}
|
||
>
|
||
{item.label}
|
||
</span>
|
||
<div
|
||
className={cn(
|
||
'h-1 flex-1 rounded-full transition-colors duration-300',
|
||
isActive ? 'bg-current opacity-20' : 'bg-slate-100 dark:bg-slate-700',
|
||
)}
|
||
/>
|
||
</div>
|
||
{/* Pulsing dot — always rendered, opacity-controlled, no layout shift */}
|
||
<motion.div
|
||
className="size-1.5 rounded-full bg-violet-500"
|
||
animate={{ opacity: isActive ? [1, 0.3, 1] : 0 }}
|
||
transition={isActive ? { duration: 0.8, repeat: Infinity } : { duration: 0.2 }}
|
||
/>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|