175 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|