Spaces:
Sleeping
Sleeping
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | 'use client' | |
| import Image from 'next/image' | |
| import { useEffect } from 'react' | |
| import { useTranslations } from 'next-intl' | |
| import { APP_VERSION } from '@/lib/version' | |
| interface InitStep { | |
| key: string | |
| label: string | |
| status: 'pending' | 'done' | |
| } | |
| interface LoaderProps { | |
| variant?: 'page' | 'panel' | 'inline' | |
| label?: string | |
| steps?: InitStep[] | |
| } | |
| const LOADER_AGENTS = [ | |
| { | |
| key: 'claude', | |
| name: 'Claude', | |
| src: '/brand/claude-logo.png', | |
| wrapperClass: 'absolute left-1/2 top-0 -translate-x-1/2 opacity-0 animate-converge-top', | |
| labelClass: 'absolute left-1/2 -top-5 -translate-x-1/2', | |
| }, | |
| { | |
| key: 'openclaw', | |
| name: 'OpenClaw', | |
| src: '/brand/openclaw-logo.png', | |
| wrapperClass: 'absolute left-0 top-1/2 -translate-y-1/2 opacity-0 animate-converge-left', | |
| labelClass: 'absolute -left-9 top-1/2 -translate-y-1/2', | |
| }, | |
| { | |
| key: 'codex', | |
| name: 'Codex', | |
| src: '/brand/codex-logo.png', | |
| wrapperClass: 'absolute right-0 top-1/2 -translate-y-1/2 opacity-0 animate-converge-right', | |
| labelClass: 'absolute -right-7 top-1/2 -translate-y-1/2', | |
| }, | |
| { | |
| key: 'hermes', | |
| name: 'Hermes', | |
| src: '/brand/hermes-logo.png', | |
| wrapperClass: 'absolute left-1/2 bottom-0 -translate-x-1/2 opacity-0 animate-converge-bottom', | |
| labelClass: 'absolute left-1/2 -bottom-5 -translate-x-1/2', | |
| }, | |
| ] as const | |
| const LOADER_IMAGE_SOURCES = [ | |
| ...LOADER_AGENTS.map((agent) => agent.src), | |
| '/brand/mc-logo-128.png', | |
| ] as const | |
| function LoaderDots({ size = 'md' }: { size?: 'sm' | 'md' }) { | |
| const dotSize = size === 'sm' ? 'w-1 h-1' : 'w-1.5 h-1.5' | |
| return ( | |
| <div className="flex items-center gap-1.5"> | |
| <div className={`${dotSize} rounded-full bg-void-cyan animate-pulse`} style={{ animationDelay: '0ms' }} /> | |
| <div className={`${dotSize} rounded-full bg-void-cyan animate-pulse`} style={{ animationDelay: '200ms' }} /> | |
| <div className={`${dotSize} rounded-full bg-void-cyan animate-pulse`} style={{ animationDelay: '400ms' }} /> | |
| </div> | |
| ) | |
| } | |
| function PageLoader({ steps }: { steps?: InitStep[] }) { | |
| const t = useTranslations('boot') | |
| useEffect(() => { | |
| const createdLinks: HTMLLinkElement[] = [] | |
| for (const href of LOADER_IMAGE_SOURCES) { | |
| const existing = document.head.querySelector(`link[rel="preload"][href="${href}"]`) | |
| if (!existing) { | |
| const link = document.createElement('link') | |
| link.rel = 'preload' | |
| link.as = 'image' | |
| link.href = href | |
| document.head.appendChild(link) | |
| createdLinks.push(link) | |
| } | |
| const img = new window.Image() | |
| img.src = href | |
| } | |
| return () => { | |
| for (const link of createdLinks) { | |
| link.remove() | |
| } | |
| } | |
| }, []) | |
| const doneCount = steps?.filter(s => s.status === 'done').length ?? 0 | |
| const totalCount = steps?.length ?? 1 | |
| const progress = steps ? (doneCount / totalCount) * 100 : 0 | |
| const allDone = steps ? doneCount === totalCount : false | |
| const activeStep = steps?.find(s => s.status === 'pending') | |
| return ( | |
| <div | |
| className={`flex items-center justify-center min-h-screen bg-background void-bg transition-opacity duration-300 ${allDone ? 'opacity-0' : 'opacity-100'}`} | |
| > | |
| <div className="flex flex-col items-center gap-8 w-64"> | |
| {/* Animated logo sequence: OpenClaw + Claude converge → morph into MC mark */} | |
| <div className="relative flex items-center justify-center h-28 w-full"> | |
| {/* Ambient glow */} | |
| <div | |
| className="absolute w-28 h-28 rounded-full bg-primary/8 blur-2xl animate-glow-pulse" | |
| style={{ animationDelay: '2.2s' }} | |
| /> | |
| {/* Phase 1: Four logos converge from cardinal directions (fades out at 1.8s) */} | |
| <div className="absolute inset-0 flex items-center justify-center animate-pair-fade-out"> | |
| <div className="relative w-28 h-28"> | |
| {LOADER_AGENTS.map((agent) => ( | |
| <div key={agent.key} className={agent.wrapperClass}> | |
| <div className="relative"> | |
| <Image | |
| src={agent.src} | |
| alt={agent.name} | |
| width={36} | |
| height={36} | |
| priority | |
| className="w-9 h-9 rounded-lg border border-border/60 bg-card/90 shadow-[0_0_24px_rgba(14,165,233,0.12)]" | |
| /> | |
| <span className={`${agent.labelClass} rounded-full border border-border/50 bg-background/85 px-1.5 py-0.5 text-[9px] font-mono uppercase tracking-[0.18em] text-muted-foreground shadow-sm`}> | |
| {agent.name} | |
| </span> | |
| </div> | |
| </div> | |
| ))} | |
| {/* Center burst */} | |
| <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-1 rounded-full bg-primary opacity-0 animate-converge-burst" /> | |
| </div> | |
| </div> | |
| {/* Phase 2: MC mark emerges (fades in at 2.0s) */} | |
| <div className="absolute inset-0 flex items-center justify-center opacity-0 animate-mc-fade-in"> | |
| <div className="animate-float" style={{ animationDelay: '2.7s' }}> | |
| <Image | |
| src="/brand/mc-logo-128.png" | |
| alt="Mission Control" | |
| width={56} | |
| height={56} | |
| priority | |
| fetchPriority="high" | |
| className="w-14 h-14" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Title */} | |
| <div className="flex flex-col items-center gap-1"> | |
| <h1 className="font-mono text-sm tracking-[0.2em] uppercase text-foreground font-medium"> | |
| {t('missionControl')} | |
| </h1> | |
| <p className="text-2xs text-muted-foreground/60"> | |
| {t('agentOrchestration')} | |
| </p> | |
| </div> | |
| {/* Progress section — appears after logo animation, only while loading */} | |
| {steps ? ( | |
| <div | |
| className="w-full flex flex-col items-center gap-3 opacity-0" | |
| style={{ animation: 'mcFadeIn 0.6s ease-out 2.4s forwards' }} | |
| > | |
| {/* Progress bar */} | |
| <div className="w-full h-0.5 bg-border/50 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-primary shimmer-bar rounded-full transition-all duration-500 ease-out" | |
| style={{ width: `${progress}%` }} | |
| /> | |
| </div> | |
| {/* Active step label — crossfades on step change */} | |
| <div className="h-5 flex items-center justify-center"> | |
| {activeStep && ( | |
| <div | |
| key={activeStep.key} | |
| className="flex items-center gap-2" | |
| style={{ animation: 'fadeIn 0.3s ease-out' }} | |
| > | |
| <div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" /> | |
| <span className="font-mono text-2xs tracking-wide text-muted-foreground"> | |
| {activeStep.label} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) : ( | |
| /* SSR fallback — no progress data yet */ | |
| <LoaderDots /> | |
| )} | |
| {/* Version */} | |
| <span className="text-2xs font-mono text-muted-foreground/40"> | |
| v{APP_VERSION} | |
| </span> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export function Loader({ variant = 'panel', label, steps }: LoaderProps) { | |
| if (variant === 'page') { | |
| return <PageLoader steps={steps} /> | |
| } | |
| if (variant === 'inline') { | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <LoaderDots size="sm" /> | |
| {label && <span className="text-sm text-muted-foreground">{label}</span>} | |
| </div> | |
| ) | |
| } | |
| // panel (default) | |
| return ( | |
| <div className="flex items-center justify-center py-12"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <LoaderDots /> | |
| {label && <span className="text-sm text-muted-foreground">{label}</span>} | |
| </div> | |
| </div> | |
| ) | |
| } | |