'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 (
)
}
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 (
{/* Animated logo sequence: OpenClaw + Claude converge → morph into MC mark */}
{/* Ambient glow */}
{/* Phase 1: Four logos converge from cardinal directions (fades out at 1.8s) */}
{LOADER_AGENTS.map((agent) => (
))}
{/* Center burst */}
{/* Phase 2: MC mark emerges (fades in at 2.0s) */}
{/* Title */}
{t('missionControl')}
{t('agentOrchestration')}
{/* Progress section — appears after logo animation, only while loading */}
{steps ? (
{/* Progress bar */}
{/* Active step label — crossfades on step change */}
) : (
/* SSR fallback — no progress data yet */
)}
{/* Version */}
v{APP_VERSION}
)
}
export function Loader({ variant = 'panel', label, steps }: LoaderProps) {
if (variant === 'page') {
return
}
if (variant === 'inline') {
return (
{label && {label}}
)
}
// panel (default)
return (
)
}