| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- 'use client';
- import { useEffect, useState, useRef } from 'react';
- import { motion, AnimatePresence } from 'motion/react';
- import { Sparkles, X } from 'lucide-react';
- import { cn } from '@/lib/utils';
- import { useI18n } from '@/lib/hooks/use-i18n';
- interface AgentRevealModalProps {
- agents: Array<{
- id: string;
- name: string;
- role: string;
- persona: string;
- avatar: string;
- color: string;
- }>;
- open: boolean;
- onClose: () => void;
- /** Called once after all cards are revealed — signals generation can continue */
- onAllRevealed?: () => void;
- }
- function isUrl(str: string): boolean {
- return str.startsWith('http') || str.startsWith('/') || str.startsWith('data:');
- }
- /** Lighten a hex color by mixing with white */
- function lighten(hex: string, amount: number): string {
- const r = parseInt(hex.slice(1, 3), 16);
- const g = parseInt(hex.slice(3, 5), 16);
- const b = parseInt(hex.slice(5, 7), 16);
- const lr = Math.round(r + (255 - r) * amount);
- const lg = Math.round(g + (255 - g) * amount);
- const lb = Math.round(b + (255 - b) * amount);
- return `rgb(${lr},${lg},${lb})`;
- }
- const ROLE_ICONS: Record<string, string> = {
- teacher: '👨🏫',
- assistant: '🤝',
- student: '🎓',
- };
- export function AgentRevealModal({ agents, open, onClose, onAllRevealed }: AgentRevealModalProps) {
- const { t } = useI18n();
- const [revealedCount, setRevealedCount] = useState(0);
- const [flipsComplete, setFlipsComplete] = useState(false);
- const allRevealedFiredRef = useRef(false);
- const onAllRevealedRef = useRef(onAllRevealed);
- onAllRevealedRef.current = onAllRevealed;
- const allRevealed = revealedCount >= agents.length && agents.length > 0;
- useEffect(() => {
- if (!open) {
- setRevealedCount(0);
- setFlipsComplete(false);
- allRevealedFiredRef.current = false;
- return;
- }
- let i = 0;
- const startTimeout = setTimeout(() => {
- i = 1;
- setRevealedCount(1);
- if (agents.length <= 1) {
- setTimeout(() => {
- if (!allRevealedFiredRef.current) {
- allRevealedFiredRef.current = true;
- onAllRevealedRef.current?.();
- }
- }, 600);
- return;
- }
- const interval = setInterval(() => {
- i++;
- setRevealedCount(i);
- if (i >= agents.length) {
- clearInterval(interval);
- setTimeout(() => {
- if (!allRevealedFiredRef.current) {
- allRevealedFiredRef.current = true;
- onAllRevealedRef.current?.();
- }
- }, 600);
- }
- }, 500);
- return () => clearInterval(interval);
- }, 400);
- return () => clearTimeout(startTimeout);
- }, [open, agents.length]);
- // Switch from preserve-3d to flat after all flip animations complete to enable scrolling
- useEffect(() => {
- if (!allRevealed) return;
- const timer = setTimeout(() => setFlipsComplete(true), 800);
- return () => clearTimeout(timer);
- }, [allRevealed]);
- return (
- <AnimatePresence>
- {open && (
- <motion.div
- 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"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- transition={{ duration: 0.3 }}
- >
- {/* Close button */}
- {allRevealed && (
- <motion.button
- 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"
- initial={{ opacity: 0, scale: 0.8 }}
- animate={{ opacity: 1, scale: 1 }}
- transition={{ delay: 0.2, duration: 0.2 }}
- onClick={onClose}
- >
- <X className="size-5" />
- </motion.button>
- )}
- {/* Title */}
- <motion.h2
- className="mb-8 text-2xl font-bold text-zinc-800 drop-shadow-sm dark:text-white dark:drop-shadow-lg md:text-3xl"
- initial={{ opacity: 0, y: -20 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.1, duration: 0.4 }}
- >
- <Sparkles className="mr-2 inline-block size-6 text-amber-500 dark:text-yellow-400" />
- {t('generation.agentRevealTitle')}
- </motion.h2>
- {/* Cards */}
- <div className="flex flex-wrap items-stretch justify-center gap-4 px-4 md:gap-5">
- {agents.map((agent, index) => {
- const isRevealed = index < revealedCount;
- const roleIcon = ROLE_ICONS[agent.role] ?? '🎓';
- return (
- <motion.div
- key={agent.id}
- className="group relative"
- style={{ width: 196, height: 290, perspective: 900 }}
- initial={{ opacity: 0, scale: 0.8 }}
- animate={{ opacity: 1, scale: 1 }}
- transition={{ delay: index * 0.08, duration: 0.3 }}
- >
- <motion.div
- className="relative size-full"
- style={{
- transformStyle: flipsComplete ? 'flat' : 'preserve-3d',
- }}
- animate={{ rotateY: isRevealed ? 0 : 180 }}
- transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
- >
- {/* ====== FRONT FACE ====== */}
- <div
- className="absolute inset-0 overflow-clip rounded-2xl"
- style={{ backfaceVisibility: 'hidden' }}
- >
- {/* Outer colored border */}
- <div
- className="absolute inset-0 rounded-2xl p-[2px]"
- style={{
- background: `linear-gradient(160deg, ${agent.color}, ${lighten(agent.color, 0.35)}, ${agent.color})`,
- }}
- >
- {/* Inner card body */}
- <div className="relative flex size-full flex-col overflow-clip rounded-[14px] bg-white dark:bg-zinc-900">
- {/* Top gradient band with texture */}
- <div className="relative shrink-0 overflow-hidden" style={{ height: 56 }}>
- {/* Color gradient fill */}
- <div
- className="absolute inset-0"
- style={{
- background: `linear-gradient(135deg, ${agent.color}30 0%, ${agent.color}10 100%)`,
- }}
- />
- {/* Subtle noise texture */}
- <div
- className="absolute inset-0 opacity-[0.04]"
- style={{
- 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")`,
- }}
- />
- {/* Decorative corner accent lines */}
- <svg
- className="absolute right-0 top-0 size-16 text-white/[0.06]"
- viewBox="0 0 64 64"
- >
- <line
- x1="64"
- y1="0"
- x2="0"
- y2="64"
- stroke="currentColor"
- strokeWidth="1"
- />
- <line
- x1="64"
- y1="16"
- x2="16"
- y2="64"
- stroke="currentColor"
- strokeWidth="1"
- />
- <line
- x1="64"
- y1="32"
- x2="32"
- y2="64"
- stroke="currentColor"
- strokeWidth="1"
- />
- </svg>
- </div>
- {/* Avatar — overlapping the band */}
- <div className="relative z-10 -mt-7 flex justify-center">
- <div
- className="flex size-[50px] items-center justify-center rounded-full border-[2.5px] shadow-lg shadow-black/40"
- style={{
- borderColor: agent.color,
- backgroundColor: '#f8f8fc',
- }}
- >
- {isUrl(agent.avatar) ? (
- <img
- src={agent.avatar}
- alt={agent.name}
- className="size-full rounded-full object-cover"
- />
- ) : (
- <span className="text-2xl">
- {agent.avatar || agent.name.charAt(0)}
- </span>
- )}
- </div>
- </div>
- {/* Name + role row */}
- <div className="mt-1.5 flex flex-col items-center gap-0.5 px-3">
- <h3
- className="max-w-full truncate text-center text-[13px] font-bold tracking-wide"
- style={{ color: agent.color }}
- >
- {agent.name}
- </h3>
- <span
- className="inline-flex items-center gap-1 rounded-full px-2 py-px text-[10px] font-medium"
- style={{
- color: agent.color,
- backgroundColor: `${agent.color}12`,
- }}
- >
- <span className="text-[9px]">{roleIcon}</span>
- {t(`settings.agentRoles.${agent.role}`)}
- </span>
- </div>
- {/* Thin ornamental divider */}
- <div className="mx-5 mt-2 flex items-center gap-2">
- <div
- className="h-px flex-1"
- style={{
- background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)`,
- }}
- />
- <div
- className="size-1 rounded-full"
- style={{ backgroundColor: `${agent.color}60` }}
- />
- <div
- className="h-px flex-1"
- style={{
- background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)`,
- }}
- />
- </div>
- {/* Persona text — fills remaining space */}
- <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pt-1.5 pb-3">
- <p className="text-left text-[10.5px] leading-[1.65] text-zinc-600 dark:text-zinc-400">
- {agent.persona}
- </p>
- </div>
- {/* Bottom edge glow */}
- <div
- className="pointer-events-none absolute inset-x-0 bottom-0 h-8"
- style={{
- background: `linear-gradient(to top, ${agent.color}08, transparent)`,
- }}
- />
- </div>
- </div>
- </div>
- {/* ====== BACK FACE ====== */}
- <div
- className="absolute inset-0 overflow-hidden rounded-2xl"
- style={{
- backfaceVisibility: 'hidden',
- transform: 'rotateY(180deg)',
- }}
- >
- {/* Gradient border matching front style */}
- <div
- className="absolute inset-0 rounded-2xl p-[2px]"
- style={{
- background: 'linear-gradient(160deg, #6366f1, #a855f7, #6366f1)',
- }}
- >
- <div
- className="relative flex size-full flex-col items-center justify-center rounded-[14px]"
- style={{
- background:
- 'linear-gradient(145deg, #1e1b4b 0%, #312e81 40%, #1e1b4b 100%)',
- }}
- >
- {/* Decorative inner border */}
- <div className="absolute inset-3 rounded-xl border border-white/[0.08]" />
- {/* Diamond pattern corners */}
- <svg
- className="absolute left-3 top-3 size-5 text-white/[0.07]"
- viewBox="0 0 20 20"
- >
- <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
- </svg>
- <svg
- className="absolute right-3 top-3 size-5 text-white/[0.07]"
- viewBox="0 0 20 20"
- >
- <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
- </svg>
- <svg
- className="absolute bottom-3 left-3 size-5 text-white/[0.07]"
- viewBox="0 0 20 20"
- >
- <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
- </svg>
- <svg
- className="absolute bottom-3 right-3 size-5 text-white/[0.07]"
- viewBox="0 0 20 20"
- >
- <path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
- </svg>
- {/* Center icon */}
- <Sparkles className="size-9 text-purple-300/70" />
- <span className="mt-1.5 text-xl font-bold text-purple-200/60">?</span>
- </div>
- </div>
- </div>
- </motion.div>
- </motion.div>
- );
- })}
- </div>
- {/* Progress dots + continue */}
- <motion.div
- className="mt-6 flex flex-col items-center gap-4"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ delay: 0.3 }}
- >
- <div className="flex gap-1.5">
- {agents.map((_, index) => (
- <div
- key={index}
- className={cn(
- 'size-2 rounded-full transition-colors duration-300',
- index < revealedCount
- ? 'bg-zinc-700 dark:bg-white'
- : 'bg-zinc-300 dark:bg-white/30',
- )}
- />
- ))}
- </div>
- {allRevealed && (
- <motion.button
- 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"
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.3, duration: 0.3 }}
- onClick={onClose}
- >
- {t('generation.continue')}
- </motion.button>
- )}
- </motion.div>
- </motion.div>
- )}
- </AnimatePresence>
- );
- }
|