agent-reveal-modal.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. 'use client';
  2. import { useEffect, useState, useRef } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import { Sparkles, X } from 'lucide-react';
  5. import { cn } from '@/lib/utils';
  6. import { useI18n } from '@/lib/hooks/use-i18n';
  7. interface AgentRevealModalProps {
  8. agents: Array<{
  9. id: string;
  10. name: string;
  11. role: string;
  12. persona: string;
  13. avatar: string;
  14. color: string;
  15. }>;
  16. open: boolean;
  17. onClose: () => void;
  18. /** Called once after all cards are revealed — signals generation can continue */
  19. onAllRevealed?: () => void;
  20. }
  21. function isUrl(str: string): boolean {
  22. return str.startsWith('http') || str.startsWith('/') || str.startsWith('data:');
  23. }
  24. /** Lighten a hex color by mixing with white */
  25. function lighten(hex: string, amount: number): string {
  26. const r = parseInt(hex.slice(1, 3), 16);
  27. const g = parseInt(hex.slice(3, 5), 16);
  28. const b = parseInt(hex.slice(5, 7), 16);
  29. const lr = Math.round(r + (255 - r) * amount);
  30. const lg = Math.round(g + (255 - g) * amount);
  31. const lb = Math.round(b + (255 - b) * amount);
  32. return `rgb(${lr},${lg},${lb})`;
  33. }
  34. const ROLE_ICONS: Record<string, string> = {
  35. teacher: '👨‍🏫',
  36. assistant: '🤝',
  37. student: '🎓',
  38. };
  39. export function AgentRevealModal({ agents, open, onClose, onAllRevealed }: AgentRevealModalProps) {
  40. const { t } = useI18n();
  41. const [revealedCount, setRevealedCount] = useState(0);
  42. const [flipsComplete, setFlipsComplete] = useState(false);
  43. const allRevealedFiredRef = useRef(false);
  44. const onAllRevealedRef = useRef(onAllRevealed);
  45. onAllRevealedRef.current = onAllRevealed;
  46. const allRevealed = revealedCount >= agents.length && agents.length > 0;
  47. useEffect(() => {
  48. if (!open) {
  49. setRevealedCount(0);
  50. setFlipsComplete(false);
  51. allRevealedFiredRef.current = false;
  52. return;
  53. }
  54. let i = 0;
  55. const startTimeout = setTimeout(() => {
  56. i = 1;
  57. setRevealedCount(1);
  58. if (agents.length <= 1) {
  59. setTimeout(() => {
  60. if (!allRevealedFiredRef.current) {
  61. allRevealedFiredRef.current = true;
  62. onAllRevealedRef.current?.();
  63. }
  64. }, 600);
  65. return;
  66. }
  67. const interval = setInterval(() => {
  68. i++;
  69. setRevealedCount(i);
  70. if (i >= agents.length) {
  71. clearInterval(interval);
  72. setTimeout(() => {
  73. if (!allRevealedFiredRef.current) {
  74. allRevealedFiredRef.current = true;
  75. onAllRevealedRef.current?.();
  76. }
  77. }, 600);
  78. }
  79. }, 500);
  80. return () => clearInterval(interval);
  81. }, 400);
  82. return () => clearTimeout(startTimeout);
  83. }, [open, agents.length]);
  84. // Switch from preserve-3d to flat after all flip animations complete to enable scrolling
  85. useEffect(() => {
  86. if (!allRevealed) return;
  87. const timer = setTimeout(() => setFlipsComplete(true), 800);
  88. return () => clearTimeout(timer);
  89. }, [allRevealed]);
  90. return (
  91. <AnimatePresence>
  92. {open && (
  93. <motion.div
  94. className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-zinc-100/90 backdrop-blur-sm dark:bg-black/70 dark:backdrop-blur-md"
  95. initial={{ opacity: 0 }}
  96. animate={{ opacity: 1 }}
  97. exit={{ opacity: 0 }}
  98. transition={{ duration: 0.3 }}
  99. >
  100. {/* Close button */}
  101. {allRevealed && (
  102. <motion.button
  103. className="absolute right-4 top-4 flex size-9 items-center justify-center rounded-full bg-black/5 text-zinc-500 backdrop-blur-sm transition-colors hover:bg-black/10 hover:text-zinc-700 dark:bg-white/10 dark:text-white/70 dark:hover:bg-white/20 dark:hover:text-white"
  104. initial={{ opacity: 0, scale: 0.8 }}
  105. animate={{ opacity: 1, scale: 1 }}
  106. transition={{ delay: 0.2, duration: 0.2 }}
  107. onClick={onClose}
  108. >
  109. <X className="size-5" />
  110. </motion.button>
  111. )}
  112. {/* Title */}
  113. <motion.h2
  114. className="mb-8 text-2xl font-bold text-zinc-800 drop-shadow-sm dark:text-white dark:drop-shadow-lg md:text-3xl"
  115. initial={{ opacity: 0, y: -20 }}
  116. animate={{ opacity: 1, y: 0 }}
  117. transition={{ delay: 0.1, duration: 0.4 }}
  118. >
  119. <Sparkles className="mr-2 inline-block size-6 text-amber-500 dark:text-yellow-400" />
  120. {t('generation.agentRevealTitle')}
  121. </motion.h2>
  122. {/* Cards */}
  123. <div className="flex flex-wrap items-stretch justify-center gap-4 px-4 md:gap-5">
  124. {agents.map((agent, index) => {
  125. const isRevealed = index < revealedCount;
  126. const roleIcon = ROLE_ICONS[agent.role] ?? '🎓';
  127. return (
  128. <motion.div
  129. key={agent.id}
  130. className="group relative"
  131. style={{ width: 196, height: 290, perspective: 900 }}
  132. initial={{ opacity: 0, scale: 0.8 }}
  133. animate={{ opacity: 1, scale: 1 }}
  134. transition={{ delay: index * 0.08, duration: 0.3 }}
  135. >
  136. <motion.div
  137. className="relative size-full"
  138. style={{
  139. transformStyle: flipsComplete ? 'flat' : 'preserve-3d',
  140. }}
  141. animate={{ rotateY: isRevealed ? 0 : 180 }}
  142. transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
  143. >
  144. {/* ====== FRONT FACE ====== */}
  145. <div
  146. className="absolute inset-0 overflow-clip rounded-2xl"
  147. style={{ backfaceVisibility: 'hidden' }}
  148. >
  149. {/* Outer colored border */}
  150. <div
  151. className="absolute inset-0 rounded-2xl p-[2px]"
  152. style={{
  153. background: `linear-gradient(160deg, ${agent.color}, ${lighten(agent.color, 0.35)}, ${agent.color})`,
  154. }}
  155. >
  156. {/* Inner card body */}
  157. <div className="relative flex size-full flex-col overflow-clip rounded-[14px] bg-white dark:bg-zinc-900">
  158. {/* Top gradient band with texture */}
  159. <div className="relative shrink-0 overflow-hidden" style={{ height: 56 }}>
  160. {/* Color gradient fill */}
  161. <div
  162. className="absolute inset-0"
  163. style={{
  164. background: `linear-gradient(135deg, ${agent.color}30 0%, ${agent.color}10 100%)`,
  165. }}
  166. />
  167. {/* Subtle noise texture */}
  168. <div
  169. className="absolute inset-0 opacity-[0.04]"
  170. style={{
  171. backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
  172. }}
  173. />
  174. {/* Decorative corner accent lines */}
  175. <svg
  176. className="absolute right-0 top-0 size-16 text-white/[0.06]"
  177. viewBox="0 0 64 64"
  178. >
  179. <line
  180. x1="64"
  181. y1="0"
  182. x2="0"
  183. y2="64"
  184. stroke="currentColor"
  185. strokeWidth="1"
  186. />
  187. <line
  188. x1="64"
  189. y1="16"
  190. x2="16"
  191. y2="64"
  192. stroke="currentColor"
  193. strokeWidth="1"
  194. />
  195. <line
  196. x1="64"
  197. y1="32"
  198. x2="32"
  199. y2="64"
  200. stroke="currentColor"
  201. strokeWidth="1"
  202. />
  203. </svg>
  204. </div>
  205. {/* Avatar — overlapping the band */}
  206. <div className="relative z-10 -mt-7 flex justify-center">
  207. <div
  208. className="flex size-[50px] items-center justify-center rounded-full border-[2.5px] shadow-lg shadow-black/40"
  209. style={{
  210. borderColor: agent.color,
  211. backgroundColor: '#f8f8fc',
  212. }}
  213. >
  214. {isUrl(agent.avatar) ? (
  215. <img
  216. src={agent.avatar}
  217. alt={agent.name}
  218. className="size-full rounded-full object-cover"
  219. />
  220. ) : (
  221. <span className="text-2xl">
  222. {agent.avatar || agent.name.charAt(0)}
  223. </span>
  224. )}
  225. </div>
  226. </div>
  227. {/* Name + role row */}
  228. <div className="mt-1.5 flex flex-col items-center gap-0.5 px-3">
  229. <h3
  230. className="max-w-full truncate text-center text-[13px] font-bold tracking-wide"
  231. style={{ color: agent.color }}
  232. >
  233. {agent.name}
  234. </h3>
  235. <span
  236. className="inline-flex items-center gap-1 rounded-full px-2 py-px text-[10px] font-medium"
  237. style={{
  238. color: agent.color,
  239. backgroundColor: `${agent.color}12`,
  240. }}
  241. >
  242. <span className="text-[9px]">{roleIcon}</span>
  243. {t(`settings.agentRoles.${agent.role}`)}
  244. </span>
  245. </div>
  246. {/* Thin ornamental divider */}
  247. <div className="mx-5 mt-2 flex items-center gap-2">
  248. <div
  249. className="h-px flex-1"
  250. style={{
  251. background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)`,
  252. }}
  253. />
  254. <div
  255. className="size-1 rounded-full"
  256. style={{ backgroundColor: `${agent.color}60` }}
  257. />
  258. <div
  259. className="h-px flex-1"
  260. style={{
  261. background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)`,
  262. }}
  263. />
  264. </div>
  265. {/* Persona text — fills remaining space */}
  266. <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pt-1.5 pb-3">
  267. <p className="text-left text-[10.5px] leading-[1.65] text-zinc-600 dark:text-zinc-400">
  268. {agent.persona}
  269. </p>
  270. </div>
  271. {/* Bottom edge glow */}
  272. <div
  273. className="pointer-events-none absolute inset-x-0 bottom-0 h-8"
  274. style={{
  275. background: `linear-gradient(to top, ${agent.color}08, transparent)`,
  276. }}
  277. />
  278. </div>
  279. </div>
  280. </div>
  281. {/* ====== BACK FACE ====== */}
  282. <div
  283. className="absolute inset-0 overflow-hidden rounded-2xl"
  284. style={{
  285. backfaceVisibility: 'hidden',
  286. transform: 'rotateY(180deg)',
  287. }}
  288. >
  289. {/* Gradient border matching front style */}
  290. <div
  291. className="absolute inset-0 rounded-2xl p-[2px]"
  292. style={{
  293. background: 'linear-gradient(160deg, #6366f1, #a855f7, #6366f1)',
  294. }}
  295. >
  296. <div
  297. className="relative flex size-full flex-col items-center justify-center rounded-[14px]"
  298. style={{
  299. background:
  300. 'linear-gradient(145deg, #1e1b4b 0%, #312e81 40%, #1e1b4b 100%)',
  301. }}
  302. >
  303. {/* Decorative inner border */}
  304. <div className="absolute inset-3 rounded-xl border border-white/[0.08]" />
  305. {/* Diamond pattern corners */}
  306. <svg
  307. className="absolute left-3 top-3 size-5 text-white/[0.07]"
  308. viewBox="0 0 20 20"
  309. >
  310. <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
  311. </svg>
  312. <svg
  313. className="absolute right-3 top-3 size-5 text-white/[0.07]"
  314. viewBox="0 0 20 20"
  315. >
  316. <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
  317. </svg>
  318. <svg
  319. className="absolute bottom-3 left-3 size-5 text-white/[0.07]"
  320. viewBox="0 0 20 20"
  321. >
  322. <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
  323. </svg>
  324. <svg
  325. className="absolute bottom-3 right-3 size-5 text-white/[0.07]"
  326. viewBox="0 0 20 20"
  327. >
  328. <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
  329. </svg>
  330. {/* Center icon */}
  331. <Sparkles className="size-9 text-purple-300/70" />
  332. <span className="mt-1.5 text-xl font-bold text-purple-200/60">?</span>
  333. </div>
  334. </div>
  335. </div>
  336. </motion.div>
  337. </motion.div>
  338. );
  339. })}
  340. </div>
  341. {/* Progress dots + continue */}
  342. <motion.div
  343. className="mt-6 flex flex-col items-center gap-4"
  344. initial={{ opacity: 0 }}
  345. animate={{ opacity: 1 }}
  346. transition={{ delay: 0.3 }}
  347. >
  348. <div className="flex gap-1.5">
  349. {agents.map((_, index) => (
  350. <div
  351. key={index}
  352. className={cn(
  353. 'size-2 rounded-full transition-colors duration-300',
  354. index < revealedCount
  355. ? 'bg-zinc-700 dark:bg-white'
  356. : 'bg-zinc-300 dark:bg-white/30',
  357. )}
  358. />
  359. ))}
  360. </div>
  361. {allRevealed && (
  362. <motion.button
  363. className="rounded-full bg-zinc-800 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-white/15 dark:hover:bg-white/25"
  364. initial={{ opacity: 0, y: 10 }}
  365. animate={{ opacity: 1, y: 0 }}
  366. transition={{ delay: 0.3, duration: 0.3 }}
  367. onClick={onClose}
  368. >
  369. {t('generation.continue')}
  370. </motion.button>
  371. )}
  372. </motion.div>
  373. </motion.div>
  374. )}
  375. </AnimatePresence>
  376. );
  377. }