SkateSpots/src/components/SplashScreen.tsx

362 lines
14 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 = 380; // keep the brand beat, lose the artificial wait
const WARM_GRACE_MS = 180; // a short breath after relays connect
const MAX_MS = 900; // hard cap — reveal the shell even on a dead network
const FADE_MS = 280; // iris-close exit
const READY_HOLD_MS = 120; // show the ready check, then move
const SPARKS = [
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '20ms' },
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '40ms' },
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '60ms' },
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '80ms' },
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '100ms' },
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '120ms' },
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '140ms' },
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '160ms' },
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '180ms' },
];
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);
}, 380);
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.9, (85 - p) * 0.065)));
}, 60);
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={{
// The exit is an aperture-style iris closing into the logo. It reveals
// the already-painted app from the edges without moving the map. Users
// who prefer reduced motion get a simple, quick fade instead.
opacity: leaving && prefersReducedMotion ? 0 : 1,
clipPath: prefersReducedMotion
? undefined
: leaving
? 'circle(0px at 50% 42%)'
: 'circle(150vmax at 50% 42%)',
WebkitClipPath: prefersReducedMotion
? undefined
: leaving
? 'circle(0px at 50% 42%)'
: 'circle(150vmax at 50% 42%)',
transition: prefersReducedMotion
? `opacity ${FADE_MS}ms ease`
: `clip-path ${FADE_MS}ms cubic-bezier(0.7,0,0.84,0)`,
willChange: 'clip-path',
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"
style={{
// Keep the mark visible all the way through the iris-close exit.
// The shrinking aperture clips it away, so there is never a dead
// black frame between the splash and the app underneath.
opacity: 1,
transform: leaving && !prefersReducedMotion ? 'translateY(-4px)' : 'translateY(0)',
transition: 'transform 280ms cubic-bezier(0.22,1,0.36,1)',
}}
>
{/* The mark arrives from the native splash's centre and settles into the
column. No rotation: a quick, grounded drop with one landing burst. */}
<div style={anim('splashLogoHandoff 440ms cubic-bezier(0.22,1,0.36,1) both')}>
<div className="relative">
{/* Static conic halo — colour and breathing glow, no spin */}
<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 240ms ease',
}}
aria-hidden="true"
/>
{/* Mask ring interior so only a thin halo shows */}
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
{/* One-shot landing sparks, 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>
)}
<div
style={anim('splashLogoDrop 380ms cubic-bezier(0.22,1,0.36,1) both')}
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)]"
/>
{/* 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>
</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 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '0ms',
}}
>
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 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '30ms',
}}
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 240ms 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 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '60ms',
}}
>
{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;