visualizers.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import {
  5. ScanLine,
  6. Search,
  7. Globe,
  8. MousePointer2,
  9. BarChart3,
  10. Puzzle,
  11. Clapperboard,
  12. MessageSquare,
  13. Focus,
  14. Play,
  15. } from 'lucide-react';
  16. import { cn } from '@/lib/utils';
  17. import type { SceneOutline } from '@/lib/types/generation';
  18. // Step-specific visualizers
  19. export function StepVisualizer({
  20. stepId,
  21. outlines,
  22. webSearchSources,
  23. }: {
  24. stepId: string;
  25. outlines?: SceneOutline[] | null;
  26. webSearchSources?: Array<{ title: string; url: string }>;
  27. }) {
  28. switch (stepId) {
  29. case 'pdf-analysis':
  30. return <PdfScanVisualizer />;
  31. case 'web-search':
  32. return <WebSearchVisualizer sources={webSearchSources || []} />;
  33. case 'outline':
  34. return <StreamingOutlineVisualizer outlines={outlines || []} />;
  35. case 'agent-generation':
  36. return <AgentGenerationVisualizer />;
  37. case 'slide-content':
  38. return <ContentVisualizer />;
  39. case 'actions':
  40. return <ActionsVisualizer />;
  41. default:
  42. return null;
  43. }
  44. }
  45. // PDF: Document with scanning laser line
  46. function PdfScanVisualizer() {
  47. return (
  48. <div className="size-32 relative flex items-center justify-center">
  49. <motion.div
  50. className="absolute inset-2 bg-cyan-500/5 rounded-2xl blur-lg"
  51. animate={{ opacity: [0.3, 0.6, 0.3] }}
  52. transition={{ duration: 2, repeat: Infinity }}
  53. />
  54. <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">
  55. <div className="p-3 space-y-2 mt-1">
  56. {[80, 60, 90, 45, 70].map((w, i) => (
  57. <motion.div
  58. key={i}
  59. className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded"
  60. style={{ width: `${w}%` }}
  61. animate={{ opacity: [0.4, 1, 0.4] }}
  62. transition={{ duration: 2, repeat: Infinity, delay: i * 0.2 }}
  63. />
  64. ))}
  65. </div>
  66. {/* Scanning laser */}
  67. <motion.div
  68. 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)]"
  69. animate={{ top: ['5%', '90%', '5%'] }}
  70. transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
  71. />
  72. </div>
  73. <motion.div
  74. className="absolute -top-1 -right-1"
  75. animate={{ rotate: [0, 10, -10, 0] }}
  76. transition={{ duration: 3, repeat: Infinity }}
  77. >
  78. <ScanLine className="size-6 text-cyan-500/70" />
  79. </motion.div>
  80. </div>
  81. );
  82. }
  83. // Web Search: Miniature search engine results page with animated query + result rows
  84. function WebSearchVisualizer({ sources }: { sources: Array<{ title: string; url: string }> }) {
  85. const [activeResult, setActiveResult] = useState(0);
  86. // Cycle through result highlight when we have sources
  87. useEffect(() => {
  88. if (sources.length === 0) return;
  89. const timer = setInterval(() => {
  90. setActiveResult((prev) => (prev + 1) % Math.min(sources.length, 4));
  91. }, 1400);
  92. return () => clearInterval(timer);
  93. }, [sources.length]);
  94. // Placeholder results for skeleton state
  95. const skeletonResults = [
  96. { titleW: 70, urlW: 45, snippetW: [90, 60] },
  97. { titleW: 55, urlW: 50, snippetW: [80, 75] },
  98. { titleW: 65, urlW: 40, snippetW: [85, 50] },
  99. { titleW: 50, urlW: 55, snippetW: [70, 65] },
  100. ];
  101. const ROW_H = 38;
  102. return (
  103. <div className="size-56 relative flex items-center justify-center">
  104. {/* Background glow */}
  105. <motion.div
  106. className="absolute inset-0 blur-3xl rounded-full bg-teal-500/8"
  107. animate={{ scale: [1, 1.15, 1], opacity: [0.3, 0.5, 0.3] }}
  108. transition={{ duration: 3.5, repeat: Infinity }}
  109. />
  110. {/* Search results card */}
  111. <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">
  112. {/* Search bar header */}
  113. <div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700 flex items-center gap-2">
  114. <Search className="size-3 text-teal-500 shrink-0" />
  115. <div className="flex-1 h-4 bg-slate-50 dark:bg-slate-700/50 rounded-full overflow-hidden flex items-center px-2">
  116. <motion.div
  117. className="h-1.5 bg-teal-500/25 rounded-full"
  118. initial={{ width: 0 }}
  119. animate={{ width: '70%' }}
  120. transition={{ duration: 0.8, ease: 'easeOut' }}
  121. />
  122. </div>
  123. </div>
  124. {/* Results list */}
  125. <div className="p-2 space-y-0.5 relative">
  126. {/* Sliding highlight */}
  127. {sources.length > 0 && (
  128. <motion.div
  129. className="absolute left-2 right-2 rounded-lg bg-teal-500/[0.06] dark:bg-teal-400/[0.08]"
  130. style={{ height: ROW_H - 6 }}
  131. animate={{ y: activeResult * ROW_H }}
  132. transition={{ type: 'spring', stiffness: 300, damping: 28 }}
  133. />
  134. )}
  135. {sources.length === 0
  136. ? // Skeleton: pulsing result placeholders
  137. skeletonResults.map((item, i) => (
  138. <motion.div
  139. key={i}
  140. className="px-2 py-1.5 space-y-1"
  141. animate={{ opacity: [0.3, 0.7, 0.3] }}
  142. transition={{
  143. duration: 1.4,
  144. repeat: Infinity,
  145. delay: i * 0.15,
  146. }}
  147. >
  148. <div
  149. className="h-1.5 bg-teal-200/40 dark:bg-teal-800/30 rounded"
  150. style={{ width: `${item.titleW}%` }}
  151. />
  152. <div
  153. className="h-1 bg-slate-100 dark:bg-slate-700 rounded"
  154. style={{ width: `${item.urlW}%` }}
  155. />
  156. <div className="flex gap-1">
  157. {item.snippetW.map((w, j) => (
  158. <div
  159. key={j}
  160. className="h-1 bg-slate-100 dark:bg-slate-700 rounded"
  161. style={{ width: `${w * 0.5}%` }}
  162. />
  163. ))}
  164. </div>
  165. </motion.div>
  166. ))
  167. : // Live results
  168. sources.slice(0, 4).map((source, i) => {
  169. const isActive = i === activeResult;
  170. return (
  171. <motion.div
  172. key={source.url}
  173. initial={{ opacity: 0, x: -10 }}
  174. animate={{ opacity: 1, x: 0 }}
  175. transition={{ delay: i * 0.08, duration: 0.25 }}
  176. className="relative px-2 py-1.5 space-y-0.5"
  177. >
  178. <div
  179. className={cn(
  180. 'text-[8px] font-semibold truncate transition-colors duration-300 leading-tight',
  181. isActive
  182. ? 'text-teal-600 dark:text-teal-400'
  183. : 'text-slate-600 dark:text-slate-400',
  184. )}
  185. >
  186. {source.title}
  187. </div>
  188. <div className="text-[6px] text-teal-500/50 truncate leading-tight">
  189. {source.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 32)}
  190. </div>
  191. <div className="flex gap-1">
  192. <div className="h-0.5 flex-1 bg-slate-100 dark:bg-slate-700 rounded-full" />
  193. <div className="h-0.5 w-1/3 bg-slate-100 dark:bg-slate-700 rounded-full" />
  194. </div>
  195. </motion.div>
  196. );
  197. })}
  198. </div>
  199. {/* Scanning beam */}
  200. <motion.div
  201. 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"
  202. initial={{ left: '-150%' }}
  203. animate={{ left: '200%' }}
  204. transition={{
  205. duration: 2,
  206. repeat: Infinity,
  207. repeatDelay: 2,
  208. ease: 'linear',
  209. }}
  210. />
  211. </div>
  212. {/* Source count badge */}
  213. {sources.length > 0 && (
  214. <motion.div
  215. initial={{ scale: 0 }}
  216. animate={{ scale: 1 }}
  217. transition={{ type: 'spring', stiffness: 400, damping: 15 }}
  218. 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"
  219. >
  220. <Globe className="size-2.5" />
  221. {sources.length}
  222. </motion.div>
  223. )}
  224. </div>
  225. );
  226. }
  227. // Outline: Streams real outline data as it arrives from SSE
  228. function StreamingOutlineVisualizer({ outlines }: { outlines: SceneOutline[] }) {
  229. // Build display lines from outlines
  230. const allLines: string[] = [];
  231. outlines.forEach((outline, i) => {
  232. allLines.push(`${i + 1}. ${outline.title}`);
  233. outline.keyPoints?.slice(0, 2).forEach((kp) => {
  234. const text = kp.length > 18 ? kp.substring(0, 18) + '...' : kp;
  235. allLines.push(` • ${text}`);
  236. });
  237. });
  238. return (
  239. <div className="w-40 h-52 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-2xl p-4 overflow-hidden relative rotate-[-2deg] hover:rotate-0 transition-transform duration-500">
  240. <div className="absolute top-0 inset-x-0 h-1 bg-blue-500/50" />
  241. <div className="w-1/3 h-2 bg-slate-100 dark:bg-slate-700 rounded mb-3" />
  242. <div className="space-y-1.5 font-mono text-[8px] text-muted-foreground leading-tight">
  243. {allLines.length === 0 ? (
  244. // Waiting for first outline — show placeholder skeleton
  245. <div className="space-y-2">
  246. {[60, 80, 50, 70].map((w, i) => (
  247. <motion.div
  248. key={i}
  249. className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded"
  250. style={{ width: `${w}%` }}
  251. animate={{ opacity: [0.3, 0.7, 0.3] }}
  252. transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.2 }}
  253. />
  254. ))}
  255. </div>
  256. ) : (
  257. allLines.map((line, i) => (
  258. <motion.div
  259. key={i}
  260. initial={{ opacity: 0, x: -8 }}
  261. animate={{ opacity: 1, x: 0 }}
  262. className={cn(
  263. 'truncate',
  264. !line.startsWith(' ')
  265. ? 'text-blue-600 dark:text-blue-400 font-semibold text-[9px]'
  266. : 'pl-1 opacity-80',
  267. )}
  268. >
  269. {line}
  270. </motion.div>
  271. ))
  272. )}
  273. </div>
  274. <motion.div
  275. className="absolute bottom-3 right-3 size-2 bg-blue-500 rounded-full"
  276. animate={{ opacity: [0, 1, 0] }}
  277. transition={{ repeat: Infinity, duration: 0.8 }}
  278. />
  279. </div>
  280. );
  281. }
  282. // Content: Cycles through distinct representations of Slides, Quiz, PBL, Interactive
  283. function AgentGenerationVisualizer() {
  284. return (
  285. <div className="w-60 h-40 mx-auto flex items-center justify-center">
  286. <div className="flex gap-3">
  287. {[0, 1, 2].map((i) => (
  288. <motion.div
  289. key={i}
  290. 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"
  291. animate={{ y: [0, -8, 0], rotateZ: [0, 3, -3, 0] }}
  292. transition={{
  293. duration: 1.5,
  294. repeat: Infinity,
  295. delay: i * 0.3,
  296. ease: 'easeInOut',
  297. }}
  298. >
  299. <div className="w-full h-full flex items-center justify-center text-white/80 text-lg font-bold">
  300. ?
  301. </div>
  302. </motion.div>
  303. ))}
  304. </div>
  305. </div>
  306. );
  307. }
  308. function ContentVisualizer() {
  309. const [index, setIndex] = useState(0);
  310. // 0: Slide (Blue)
  311. // 1: Quiz (Purple)
  312. // 2: PBL (Amber)
  313. // 3: Interactive (Emerald)
  314. const totalTypes = 4;
  315. useEffect(() => {
  316. const timer = setInterval(() => {
  317. setIndex((prev) => (prev + 1) % totalTypes);
  318. }, 3200);
  319. return () => clearInterval(timer);
  320. }, []);
  321. const variants = {
  322. enter: { x: 50, opacity: 0, scale: 0.9, rotateY: -15 },
  323. center: { x: 0, opacity: 1, scale: 1, rotateY: 0 },
  324. exit: { x: -50, opacity: 0, scale: 0.9, rotateY: 15 },
  325. };
  326. const getTheme = (idx: number) => {
  327. switch (idx) {
  328. case 0:
  329. return {
  330. color: 'blue',
  331. label: 'SLIDE',
  332. badge:
  333. 'bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-800',
  334. };
  335. case 1:
  336. return {
  337. color: 'purple',
  338. label: 'QUIZ',
  339. badge:
  340. 'bg-purple-100 text-purple-600 border-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:border-purple-800',
  341. };
  342. case 2:
  343. return {
  344. color: 'amber',
  345. label: 'PBL',
  346. badge:
  347. 'bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:border-amber-800',
  348. };
  349. case 3:
  350. return {
  351. color: 'emerald',
  352. label: 'WEB',
  353. badge:
  354. 'bg-emerald-100 text-emerald-600 border-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300 dark:border-emerald-800',
  355. };
  356. default:
  357. return { color: 'blue', label: '', badge: '' };
  358. }
  359. };
  360. const theme = getTheme(index);
  361. return (
  362. <div className="size-56 relative flex items-center justify-center perspective-[800px]">
  363. {/* Background glow based on current theme */}
  364. <motion.div
  365. key={`glow-${index}`}
  366. className={cn(
  367. 'absolute inset-0 blur-3xl rounded-full transition-colors duration-1000',
  368. theme.color === 'blue' && 'bg-blue-500/10',
  369. theme.color === 'purple' && 'bg-purple-500/10',
  370. theme.color === 'amber' && 'bg-amber-500/10',
  371. theme.color === 'emerald' && 'bg-emerald-500/10',
  372. )}
  373. animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.6, 0.3] }}
  374. transition={{ duration: 4, repeat: Infinity }}
  375. />
  376. {/* Subtle orbiting rings (pushed back, slower) */}
  377. {[0, 1].map((i) => (
  378. <motion.div
  379. key={i}
  380. className={cn(
  381. 'absolute border rounded-full transition-colors duration-1000',
  382. theme.color === 'blue' && 'border-blue-500/10',
  383. theme.color === 'purple' && 'border-purple-500/10',
  384. theme.color === 'amber' && 'border-amber-500/10',
  385. theme.color === 'emerald' && 'border-emerald-500/10',
  386. )}
  387. style={{
  388. width: 180 + i * 50,
  389. height: 180 + i * 50,
  390. borderStyle: 'dashed',
  391. }}
  392. animate={{ rotate: 360 }}
  393. transition={{
  394. duration: 40 + i * 15,
  395. ease: 'linear',
  396. repeat: Infinity,
  397. delay: i * -5,
  398. }}
  399. />
  400. ))}
  401. {/* Main Content Container */}
  402. <div className="w-40 h-28 relative">
  403. <AnimatePresence mode="popLayout">
  404. <motion.div
  405. key={index}
  406. variants={variants}
  407. initial="enter"
  408. animate="center"
  409. exit="exit"
  410. transition={{ type: 'spring', stiffness: 80, damping: 16 }}
  411. className={cn(
  412. 'absolute inset-0 bg-white dark:bg-slate-800 rounded-xl border shadow-xl overflow-hidden flex flex-col p-3 origin-center',
  413. theme.color === 'blue' && 'border-blue-200 dark:border-blue-900/30',
  414. theme.color === 'purple' && 'border-purple-200 dark:border-purple-900/30',
  415. theme.color === 'amber' && 'border-amber-200 dark:border-amber-900/30',
  416. theme.color === 'emerald' && 'border-emerald-200 dark:border-emerald-900/30',
  417. )}
  418. >
  419. {/* Consistent Badge - Now outside content logic */}
  420. <motion.div
  421. initial={{ opacity: 0, scale: 0.8 }}
  422. animate={{ opacity: 1, scale: 1 }}
  423. transition={{ delay: 0.1 }}
  424. className={cn(
  425. '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',
  426. theme.badge,
  427. )}
  428. >
  429. {theme.label}
  430. </motion.div>
  431. {/* --- SLIDE TYPE --- */}
  432. {index === 0 && (
  433. <div className="flex flex-col h-full pt-1">
  434. <motion.div
  435. initial={{ width: '0%' }}
  436. animate={{ width: '55%' }}
  437. transition={{ delay: 0.2 }}
  438. className="h-2 bg-blue-500/20 rounded-full mb-3 shrink-0"
  439. />
  440. <div className="flex gap-2 flex-1">
  441. <div className="flex-1 space-y-2">
  442. {[0.8, 0.9, 0.6, 0.7].map((w, i) => (
  443. <motion.div
  444. key={i}
  445. initial={{ width: 0 }}
  446. animate={{ width: `${w * 100}%` }}
  447. transition={{ delay: 0.3 + i * 0.1 }}
  448. className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full"
  449. />
  450. ))}
  451. </div>
  452. <motion.div
  453. initial={{ scale: 0.8, opacity: 0 }}
  454. animate={{ scale: 1, opacity: 1 }}
  455. transition={{ delay: 0.4 }}
  456. className="w-12 h-12 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center shrink-0"
  457. >
  458. <BarChart3 className="size-6 text-blue-500/60" />
  459. </motion.div>
  460. </div>
  461. </div>
  462. )}
  463. {/* --- QUIZ TYPE --- */}
  464. {index === 1 && (
  465. <div className="flex flex-col h-full justify-center space-y-2 pt-2">
  466. <motion.div
  467. initial={{ y: -5, opacity: 0 }}
  468. animate={{ y: 0, opacity: 1 }}
  469. transition={{ delay: 0.2 }}
  470. className="flex justify-center mb-1"
  471. >
  472. <div className="h-2 w-3/4 bg-purple-500/20 rounded-full" />
  473. </motion.div>
  474. <div className="grid grid-cols-2 gap-2">
  475. {[0, 1, 2, 3].map((i) => (
  476. <motion.div
  477. key={i}
  478. initial={{ scale: 0.8, opacity: 0 }}
  479. animate={{ scale: 1, opacity: 1 }}
  480. transition={{ delay: 0.3 + i * 0.1 }}
  481. className={cn(
  482. 'h-6 rounded border flex items-center px-2',
  483. i === 1
  484. ? 'bg-purple-500 text-white border-purple-500'
  485. : 'bg-slate-50 dark:bg-slate-700/50 border-slate-100 dark:border-slate-700',
  486. )}
  487. >
  488. <div
  489. className={cn(
  490. 'size-1.5 rounded-full mr-2',
  491. i === 1 ? 'bg-white' : 'bg-slate-300',
  492. )}
  493. />
  494. <div
  495. className={cn(
  496. 'h-1 w-8 rounded-full',
  497. i === 1 ? 'bg-white/50' : 'bg-slate-200 dark:bg-slate-600',
  498. )}
  499. />
  500. </motion.div>
  501. ))}
  502. </div>
  503. </div>
  504. )}
  505. {/* --- PBL TYPE --- */}
  506. {index === 2 && (
  507. <div className="flex flex-col h-full pt-1">
  508. <div className="flex items-center gap-2 mb-2">
  509. <Puzzle className="size-3 text-amber-500 shrink-0" />
  510. <motion.div
  511. initial={{ width: 0 }}
  512. animate={{ width: '40%' }}
  513. className="h-2 bg-amber-500/20 rounded-full"
  514. />
  515. </div>
  516. <div className="flex-1 flex gap-2 overflow-hidden">
  517. {[0, 1, 2].map((col) => (
  518. <motion.div
  519. key={col}
  520. initial={{ y: 20, opacity: 0 }}
  521. animate={{ y: 0, opacity: 1 }}
  522. transition={{ delay: 0.2 + col * 0.15 }}
  523. className="flex-1 bg-slate-50 dark:bg-slate-700/30 rounded flex flex-col gap-1 p-1"
  524. >
  525. <div className="h-1 w-6 bg-slate-200 dark:bg-slate-600 rounded mb-1" />
  526. {[0, 1].map((card) => (
  527. <div
  528. key={card}
  529. className="h-3 w-full bg-white dark:bg-slate-600 rounded border border-slate-100 dark:border-slate-500 shadow-sm"
  530. />
  531. ))}
  532. </motion.div>
  533. ))}
  534. </div>
  535. </div>
  536. )}
  537. {/* --- INTERACTIVE TYPE --- */}
  538. {index === 3 && (
  539. <div className="flex flex-col h-full relative pt-1">
  540. {/* Browser Chrome - Padded right to avoid badge */}
  541. <div className="flex items-center gap-1 mb-2 border-b border-slate-100 dark:border-slate-700 pb-1 pr-10">
  542. <div className="flex gap-0.5">
  543. <div className="size-1.5 rounded-full bg-red-400" />
  544. <div className="size-1.5 rounded-full bg-amber-400" />
  545. <div className="size-1.5 rounded-full bg-green-400" />
  546. </div>
  547. <div className="h-1.5 flex-1 bg-slate-100 dark:bg-slate-700 rounded-full ml-1" />
  548. </div>
  549. <div className="flex-1 flex gap-2 relative">
  550. <motion.div
  551. initial={{ opacity: 0 }}
  552. animate={{ opacity: 1 }}
  553. transition={{ delay: 0.3 }}
  554. className="w-1/3 bg-slate-50 dark:bg-slate-700/30 rounded p-1 space-y-1"
  555. >
  556. {[1, 2, 3].map((i) => (
  557. <div
  558. key={i}
  559. className="h-1 w-full bg-slate-200 dark:bg-slate-600 rounded-full"
  560. />
  561. ))}
  562. </motion.div>
  563. <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">
  564. <Globe className="size-8 text-emerald-200 dark:text-emerald-800" />
  565. <motion.div
  566. className="absolute"
  567. animate={{ x: [20, -10, 15, 0], y: [10, -15, 5, 0] }}
  568. transition={{ duration: 3, ease: 'easeInOut' }}
  569. >
  570. <MousePointer2 className="size-3 text-emerald-600 fill-emerald-600" />
  571. </motion.div>
  572. </div>
  573. </div>
  574. </div>
  575. )}
  576. {/* Scanning beam (shared) */}
  577. <motion.div
  578. 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"
  579. initial={{ left: '-150%' }}
  580. animate={{ left: '200%' }}
  581. transition={{
  582. duration: 1.5,
  583. repeat: Infinity,
  584. repeatDelay: 1,
  585. ease: 'linear',
  586. }}
  587. />
  588. </motion.div>
  589. </AnimatePresence>
  590. </div>
  591. </div>
  592. );
  593. }
  594. // Actions: Timeline of speech, spotlight, and interactions being orchestrated
  595. function ActionsVisualizer() {
  596. const [activeIdx, setActiveIdx] = useState(0);
  597. const actionItems = [
  598. {
  599. icon: MessageSquare,
  600. label: 'Speech',
  601. color: 'text-violet-500',
  602. activeBg: 'bg-violet-500/10',
  603. activeBorder: 'border-violet-200 dark:border-violet-800',
  604. },
  605. {
  606. icon: Focus,
  607. label: 'Spotlight',
  608. color: 'text-amber-500',
  609. activeBg: 'bg-amber-500/10',
  610. activeBorder: 'border-amber-200 dark:border-amber-800',
  611. },
  612. {
  613. icon: MessageSquare,
  614. label: 'Speech',
  615. color: 'text-violet-500',
  616. activeBg: 'bg-violet-500/10',
  617. activeBorder: 'border-violet-200 dark:border-violet-800',
  618. },
  619. {
  620. icon: Play,
  621. label: 'Interact',
  622. color: 'text-emerald-500',
  623. activeBg: 'bg-emerald-500/10',
  624. activeBorder: 'border-emerald-200 dark:border-emerald-800',
  625. },
  626. {
  627. icon: MessageSquare,
  628. label: 'Speech',
  629. color: 'text-violet-500',
  630. activeBg: 'bg-violet-500/10',
  631. activeBorder: 'border-violet-200 dark:border-violet-800',
  632. },
  633. ];
  634. // Row height (py-1.5 = 6px×2 padding + icon ~16px) + gap 6px ≈ 34px per row
  635. const ROW_H = 34;
  636. useEffect(() => {
  637. const timer = setInterval(() => {
  638. setActiveIdx((prev) => (prev + 1) % actionItems.length);
  639. }, 1600);
  640. return () => clearInterval(timer);
  641. // eslint-disable-next-line react-hooks/exhaustive-deps
  642. }, []);
  643. return (
  644. <div className="size-56 relative flex items-center justify-center">
  645. {/* Background pulse */}
  646. <motion.div
  647. className="absolute inset-0 blur-3xl rounded-full bg-violet-500/8"
  648. animate={{ scale: [1, 1.15, 1], opacity: [0.3, 0.5, 0.3] }}
  649. transition={{ duration: 3.5, repeat: Infinity }}
  650. />
  651. {/* Timeline card */}
  652. <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">
  653. {/* Header */}
  654. <div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700 flex items-center gap-2">
  655. <Clapperboard className="size-3 text-violet-500" />
  656. <motion.div
  657. initial={{ width: 0 }}
  658. animate={{ width: '50%' }}
  659. transition={{ delay: 0.2 }}
  660. className="h-1.5 bg-violet-500/20 rounded-full"
  661. />
  662. </div>
  663. {/* Action items */}
  664. <div className="p-2 space-y-1.5 relative">
  665. {/* Sliding highlight — absolute, animates via y transform, no layout impact */}
  666. <motion.div
  667. className="absolute left-2 right-2 rounded-lg bg-violet-500/[0.06] dark:bg-violet-400/[0.08]"
  668. style={{ height: ROW_H - 6 }}
  669. animate={{ y: activeIdx * ROW_H }}
  670. transition={{ type: 'spring', stiffness: 300, damping: 28 }}
  671. />
  672. {actionItems.map((item, i) => {
  673. const Icon = item.icon;
  674. const isActive = i === activeIdx;
  675. const isPast = i < activeIdx;
  676. return (
  677. <motion.div
  678. key={i}
  679. initial={{ opacity: 0, x: -12 }}
  680. animate={{ opacity: isPast ? 0.4 : 1, x: 0 }}
  681. transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
  682. className="relative flex items-center gap-2 px-2 py-1.5 rounded-lg"
  683. >
  684. <div
  685. className={cn(
  686. 'size-4 rounded flex items-center justify-center shrink-0 transition-colors duration-300',
  687. isActive ? item.color : 'text-slate-300 dark:text-slate-600',
  688. )}
  689. >
  690. <Icon className="size-3" />
  691. </div>
  692. <div className="flex-1 flex items-center gap-1.5">
  693. <span
  694. className={cn(
  695. 'text-[8px] font-semibold uppercase tracking-wider transition-colors duration-300',
  696. isActive ? item.color : 'text-slate-400 dark:text-slate-500',
  697. )}
  698. >
  699. {item.label}
  700. </span>
  701. <div
  702. className={cn(
  703. 'h-1 flex-1 rounded-full transition-colors duration-300',
  704. isActive ? 'bg-current opacity-20' : 'bg-slate-100 dark:bg-slate-700',
  705. )}
  706. />
  707. </div>
  708. {/* Pulsing dot — always rendered, opacity-controlled, no layout shift */}
  709. <motion.div
  710. className="size-1.5 rounded-full bg-violet-500"
  711. animate={{ opacity: isActive ? [1, 0.3, 1] : 0 }}
  712. transition={isActive ? { duration: 0.8, repeat: Infinity } : { duration: 0.2 }}
  713. />
  714. </motion.div>
  715. );
  716. })}
  717. </div>
  718. </div>
  719. </div>
  720. );
  721. }