deadhead-space-freight-tycoon/apps/web/src/layout/Layout.tsx

175 lines
6.4 KiB
TypeScript

import type { ReactNode } from "react";
import { Link, NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui";
import { useTrackScreen } from "./lastScreen";
export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
credits?: ReactNode;
clock?: ReactNode;
/** Shown under the header, e.g. the offline notice. */
banner?: ReactNode;
/** Attention badge counts per tab (decision 0.5.49). */
attention?: Partial<Record<ScreenPath, number>>;
}
/**
* HUD header with credits and game clock, the current screen, and icon
* navigation: a bottom bar on phones, a left rail from `md` (CR-005).
* `--header-h` and `--nav-h` let full-height screens (the map) fit exactly.
*/
export function Layout({
credits = "—",
clock = <GameClock />,
banner,
attention = {},
}: LayoutProps) {
useTrackScreen();
return (
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<LogoMark />
<span className="hidden font-display text-lg font-bold tracking-[0.28em] sm:inline">
DEADHEAD
</span>
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
Space Freight
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber">
<Icon name="credits" className="size-4 text-amber-300" />
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
{credits}
</span>
</div>
<div className="flex items-center gap-1.5 rounded-lg border border-space-600 bg-space-900/80 px-2.5 py-1 text-ink-300">
<Icon name="clock" className="size-4 text-cyan-300" />
<span aria-label="Game clock" className="font-display text-xs tabular-nums sm:text-sm">
{clock}
</span>
</div>
<Link
to="/settings"
aria-label="Settings"
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9"
>
<Icon name="gear" className="size-5" />
</Link>
</div>
</header>
{banner}
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
<Outlet />
</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-amber-400/50 to-transparent md:hidden"
/>
{SCREENS.map((screen) => (
<NavTab
key={screen.path}
path={screen.path}
label={screen.label}
icon={screen.icon}
badge={attention[screen.path] ?? 0}
/>
))}
</nav>
</div>
);
}
function NavTab({
path,
label,
icon,
badge,
}: {
path: ScreenPath;
label: string;
icon: (typeof SCREENS)[number]["icon"];
badge: number;
}) {
return (
<NavLink
to={path}
end={path === "/"}
className={({ isActive }) =>
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
}`
}
>
{({ isActive }) => (
<>
<span
aria-hidden="true"
className={`absolute top-0 h-0.5 w-8 rounded-full bg-amber-400 shadow-glow-amber transition-opacity md:top-1/2 md:left-0 md:h-8 md:w-0.5 md:-translate-y-1/2 ${
isActive ? "opacity-100" : "opacity-0"
}`}
/>
<span
className={`relative grid size-10 place-items-center rounded-xl transition md:size-11 ${
isActive
? "bg-amber-500/15 shadow-glow-amber"
: "group-hover:bg-white/5 group-active:scale-95"
}`}
>
<Icon name={icon} className="size-6" />
{badge > 0 && (
<span
aria-hidden="true"
data-testid={`badge-${label}`}
className="absolute -top-1 -right-1 grid h-4.5 min-w-4.5 animate-glow place-items-center rounded-full bg-rose-500 px-1 font-display text-[10px] font-bold text-white ring-2 ring-space-950"
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
<span className="font-display text-[10px] font-semibold tracking-wide uppercase sm:text-[11px] sm:tracking-wider">
{label}
</span>
</>
)}
</NavLink>
);
}
/** The game's mark: an orbit ring around an arrowhead. */
function LogoMark() {
return (
<svg aria-hidden="true" viewBox="0 0 32 32" className="size-8 shrink-0">
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#fde68a" />
<stop offset="1" stopColor="#f59e0b" />
</linearGradient>
</defs>
<circle
cx="16"
cy="16"
r="13"
fill="none"
stroke="url(#logo-grad)"
strokeWidth="2.2"
strokeDasharray="58 24"
strokeLinecap="round"
transform="rotate(-50 16 16)"
/>
<circle cx="27.5" cy="9" r="1.8" fill="#67e8f9" />
<path d="M16 7.5 22.5 22 16 18.5 9.5 22z" fill="url(#logo-grad)" />
</svg>
);
}