Spaces:
Sleeping
Sleeping
File size: 8,130 Bytes
b6ecafa b180108 b6ecafa b180108 b6ecafa b180108 b6ecafa b180108 b6ecafa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | '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>
)
}
|