SkateSpots/src/components/SplashScreen.tsx

344 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from '@/contexts/language';
/**
* Branded cold-start splash — a choreographed, premium launch moment.
*
* Nostr relays connect over WebSockets that NPool opens lazily; on a fresh load
* the first content arrives only after the handshakes complete. Rather than show
* a half-empty map that reads as "is it broken?", we present a short, intentional
* branded moment while the pre-warmed relays connect and the first spots stream
* in underneath. It NEVER traps the user:
*
* - Determinate progress driven by real milestones (mount → warm → content).
* - Reveals the instant real content arrives (`skatespots:content-ready`),
* with a short grace after connection (`skatespots:relays-warm`).
* - A hard max timeout guarantees dismissal even on a dead/slow network.
* - A minimum on-screen time prevents a jarring flash on instant loads.
*
* Shown once per browser session so in-session navigation never replays it.
* Fully respects prefers-reduced-motion and safe-area insets; identical on
* phone and desktop.
*/
const SESSION_KEY = 'splash-shown-session';
const MIN_MS = 900; // choreography needs room to breathe
const WARM_GRACE_MS = 550; // wait after connection for content to stream in
const MAX_MS = 2600; // hard cap — never block on a slow/offline network
const FADE_MS = 480;
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
const SPARKS = [
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '90ms' },
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '180ms' },
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '260ms' },
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '340ms' },
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '430ms' },
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '520ms' },
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '610ms' },
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '700ms' },
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '790ms' },
];
const STATUS_KEYS = [
'bootUi.statusWaking',
'bootUi.statusTuning',
'bootUi.statusPulling',
'bootUi.statusDropping',
];
export function SplashScreen() {
const { t } = useTranslation();
const [visible, setVisible] = useState(() => {
try {
return sessionStorage.getItem(SESSION_KEY) !== 'true';
} catch {
return true;
}
});
const [leaving, setLeaving] = useState(false);
const [ready, setReady] = useState(false);
const [statusIndex, setStatusIndex] = useState(0);
// Determinate progress (0–100). Creeps forward on its own, snaps toward
// milestones, and finishes at 100 the moment we're ready.
const [progress, setProgress] = useState(8);
const mountedAt = useRef(Date.now());
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
// Rotate the status line (paused once ready — the label swaps to "Ready").
useEffect(() => {
if (!visible || ready) return;
const id = setInterval(() => {
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
}, 640);
return () => clearInterval(id);
}, [visible, ready]);
// Ambient progress creep — asymptotically approaches ~85% so it always feels
// like it's making headway without ever "completing" before we're truly ready.
useEffect(() => {
if (!visible || ready) return;
const id = setInterval(() => {
setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045)));
}, 90);
return () => clearInterval(id);
}, [visible, ready]);
// Dismissal logic. Priority:
// 1. Real content arrived (`content-ready`) → reveal a populated app.
// 2. Relays connected (`relays-warm`) → short grace, then reveal.
// 3. Hard cap (MAX_MS) → always dismiss, even offline. Never traps anyone.
// All paths respect the MIN_MS floor, then play the 100%→hold→fade sequence.
useEffect(() => {
if (!visible) return;
let done = false;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (done) return;
done = true;
if (graceTimer) clearTimeout(graceTimer);
const elapsed = Date.now() - mountedAt.current;
const wait = Math.max(0, MIN_MS - elapsed);
setTimeout(() => {
// Land on 100% + a satisfying "ready" beat, then crossfade out.
setReady(true);
setProgress(100);
setTimeout(() => {
setLeaving(true);
setTimeout(() => {
try {
sessionStorage.setItem(SESSION_KEY, 'true');
} catch {
// ignore private-mode storage failures
}
setVisible(false);
}, FADE_MS);
}, READY_HOLD_MS);
}, wait);
};
const onContent = () => finish();
const onWarm = () => {
if (done || graceTimer) return;
// Nudge progress up on connection so the bar visibly reacts to a milestone.
setProgress((p) => Math.max(p, 78));
graceTimer = setTimeout(finish, WARM_GRACE_MS);
};
window.addEventListener('skatespots:content-ready', onContent);
window.addEventListener('skatespots:relays-warm', onWarm);
const cap = setTimeout(finish, MAX_MS);
return () => {
window.removeEventListener('skatespots:content-ready', onContent);
window.removeEventListener('skatespots:relays-warm', onWarm);
if (graceTimer) clearTimeout(graceTimer);
clearTimeout(cap);
};
}, [visible]);
if (!visible) return null;
const anim = (a: string): React.CSSProperties =>
prefersReducedMotion ? {} : { animation: a };
return (
<div
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
style={{
opacity: leaving ? 0 : 1,
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
transition: `opacity ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1), transform ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
paddingLeft: 'env(safe-area-inset-left, 0px)',
paddingRight: 'env(safe-area-inset-right, 0px)',
}}
role="status"
aria-live="polite"
aria-label={ready ? t('bootUi.ariaReady') : t('bootUi.ariaLoading')}
>
{/* Fine grid texture — subtle depth, like a skate map underlay */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
backgroundSize: '38px 38px',
maskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
}}
/>
{/* Dual breathing glow — red core with a lime undertone */}
<div
className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[110px]"
style={{
background:
'radial-gradient(circle, rgba(255,106,0,0.32) 0%, rgba(255,0,64,0.08) 46%, transparent 70%)',
...anim('splashGlow 3.4s ease-in-out infinite'),
}}
/>
<div className="relative z-10 flex flex-col items-center px-8">
{/* The orange wheel mark rolls in, keeps a slow roll, and throws sparks */}
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
{/* Rotating conic ring — orange to match the wheel, with a lime kick */}
<div
className="absolute -inset-3 rounded-full"
style={{
background:
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
opacity: ready ? 0.9 : 0.58,
filter: 'blur(7px)',
transition: 'opacity 400ms ease',
...anim('splashSpin 3.2s linear infinite'),
}}
aria-hidden="true"
/>
{/* Mask ring interior so only a thin halo shows */}
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
{/* Sparks are decorative and disabled for reduced-motion users. */}
{!prefersReducedMotion && (
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
{SPARKS.map((spark, index) => (
<span
key={index}
className="splash-spark"
style={{
'--sx': spark.sx,
'--sy': spark.sy,
'--ex': spark.ex,
'--ey': spark.ey,
animationDelay: spark.delay,
} as React.CSSProperties}
/>
))}
</div>
)}
{/* Bounce wrapper + rolling wheel. Nested transforms keep the spin
from fighting the entry animation. */}
<div
style={anim('splashLogoRollIn 720ms cubic-bezier(0.22,1,0.36,1) both')}
className="relative"
>
<div
style={anim('splashHover 1.9s ease-in-out infinite')}
className="relative"
>
<div style={anim('splashWheelRoll 5.4s linear infinite')} className="relative">
<img
src="/images/icon-1024.png"
alt="SkateSpots"
draggable={false}
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
/>
</div>
</div>
{/* Contact shadow / dust line under the wheel */}
<div
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
style={anim('splashDust 1.9s ease-in-out infinite')}
aria-hidden="true"
/>
</div>
</div>
{/* Wordmark */}
<h1
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
style={{
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '120ms',
}}
>
SKATESPOTS
</h1>
{/* Determinate progress bar with a moving shine */}
<div
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
style={{
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '220ms',
}}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
>
<div
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
style={{
width: `${progress}%`,
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
}}
>
{/* Sliding shine highlight */}
{!prefersReducedMotion && (
<span
className="absolute inset-y-0 w-1/3 rounded-full"
style={{
background:
'linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent)',
animation: 'splashShine 1.4s ease-in-out infinite',
}}
/>
)}
</div>
</div>
{/* Status line — swaps to a confident "Ready" with a check when done */}
<div
className="mt-5 flex h-4 items-center gap-2"
style={{
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '320ms',
}}
>
{ready ? (
<span
className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.28em] text-[#ccff00]"
style={anim('splashFade 300ms ease-out')}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M20 6L9 17l-5-5"
stroke="#ccff00"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{t('bootUi.ready')}
</span>
) : (
<span
key={statusIndex}
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
style={anim('splashFade 360ms ease-out')}
>
{t(STATUS_KEYS[statusIndex])}
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
</span>
)}
</div>
</div>
</div>
);
}
export default SplashScreen;