9c7605391c
- Mobile bottom tab bar (icon tabs) with the top nav hidden on small screens; content padding so it never overlaps - data: scripts run via bun (not tsx/node) so the oven/bun build image needs no node — fixes the Dockerfile data:build step - README: features, data sources, dev/test/build/deploy, env vars - Validated the production Docker image end-to-end: build succeeds (378MB), container serves /healthz, /api/snapshot (104 fixtures), /api/predictions (20k sims), the SPA with deep-link fallback, and the story dataset Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { Link, Outlet } from '@tanstack/react-router';
|
|
import { GitMerge, Moon, Radio, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { useUiStore } from '@/stores/uiStore';
|
|
import { useTournamentStore } from '@/stores/tournamentStore';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [
|
|
{ to: '/', label: 'Live', exact: true, icon: Radio },
|
|
{ to: '/groups', label: 'Groups', exact: false, icon: Table },
|
|
{ to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge },
|
|
{ to: '/predict', label: 'Predict', exact: false, icon: TrendingUp },
|
|
{ to: '/story', label: 'Story', exact: false, icon: Sparkles },
|
|
];
|
|
|
|
function ConnectionStatus() {
|
|
const status = useTournamentStore((s) => s.status);
|
|
const source = useTournamentStore((s) => s.snapshot?.source);
|
|
if (status === 'live') {
|
|
return (
|
|
<span
|
|
title={source === 'sofascore' ? 'Live · SofaScore' : source === 'football-data' ? 'Live · football-data.org' : 'Connected'}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-accent-deep/40 bg-accent-glow px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent"
|
|
>
|
|
<span className="live-dot h-1.5 w-1.5 rounded-full bg-accent" />
|
|
Live
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
title={status === 'connecting' ? 'Connecting…' : 'Offline — showing the schedule'}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-muted"
|
|
>
|
|
<span className="h-1.5 w-1.5 rounded-full bg-faint" />
|
|
{status === 'connecting' ? '…' : 'Offline'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ThemeToggle() {
|
|
const theme = useUiStore((s) => s.theme);
|
|
const toggle = useUiStore((s) => s.toggleTheme);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
className="grid h-9 w-9 place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink transition-colors"
|
|
>
|
|
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function RootLayout() {
|
|
useEffect(() => {
|
|
useTournamentStore.getState().init();
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex min-h-full flex-col">
|
|
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
|
|
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
|
|
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
|
|
<span className="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-b from-accent-soft to-accent text-accent-ink">
|
|
<Trophy size={16} />
|
|
</span>
|
|
Cup<span className="text-accent">26</span>
|
|
</Link>
|
|
<nav className="hidden items-center gap-0.5 sm:flex">
|
|
{NAV.map((item) => (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
{...(item.exact ? { activeOptions: { exact: true } } : {})}
|
|
className={cn(
|
|
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-elevated hover:text-ink',
|
|
'[&.active]:bg-accent-glow [&.active]:text-accent',
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<ConnectionStatus />
|
|
<ThemeToggle />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:pb-6">
|
|
<Outlet />
|
|
</main>
|
|
|
|
{/* Mobile bottom tab bar */}
|
|
<nav className="fixed inset-x-0 bottom-0 z-30 flex border-t border-line bg-surface/95 backdrop-blur sm:hidden">
|
|
{NAV.map((item) => (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
{...(item.exact ? { activeOptions: { exact: true } } : {})}
|
|
className={cn(
|
|
'flex flex-1 flex-col items-center gap-0.5 py-2 text-[10px] font-medium text-muted',
|
|
'[&.active]:text-accent',
|
|
)}
|
|
>
|
|
<item.icon size={18} />
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<footer className="border-t border-line py-6 pb-24 text-center text-xs text-faint sm:pb-6">
|
|
<p>
|
|
Cup26 · World Cup 2026 · data from openfootball, football-data.org & StatsBomb open data ·
|
|
model-driven odds, not betting advice
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|