OpenMAIC-React / src /components /agent /agent-reveal-modal.tsx
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
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>
);
}