156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
import type { CSSProperties, ReactNode } from "react";
|
|
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
|
import { GameClock } from "../clock/GameClock";
|
|
import { SCREENS, type ScreenPath } from "../routes";
|
|
import { Icon } from "../ui";
|
|
import { useTrackScreen } from "./lastScreen";
|
|
import { MapBackdrop } from "./MapBackdrop";
|
|
import { panelIsWide, PanelHost } from "./PanelHost";
|
|
|
|
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>>;
|
|
}
|
|
|
|
/**
|
|
* The shell (CR-029): the map behind everything, a floating glass header with
|
|
* credits and game clock, the current screen in a panel, and icon navigation:
|
|
* a bottom bar on phones, a floating rail from `md`.
|
|
*/
|
|
export function Layout({
|
|
credits = "—",
|
|
clock = <GameClock />,
|
|
banner,
|
|
attention = {},
|
|
}: LayoutProps) {
|
|
useTrackScreen();
|
|
const { pathname } = useLocation();
|
|
const panelWidth = panelIsWide(pathname) ? "45rem" : "26.25rem";
|
|
return (
|
|
<div
|
|
style={{ "--panel-w": panelWidth } as CSSProperties}
|
|
className="fixed inset-0 overflow-hidden bg-space-950 text-ink-50 [--nav-h:calc(4rem+env(safe-area-inset-bottom))] md:[--nav-h:0px]"
|
|
>
|
|
<MapBackdrop />
|
|
<header className="absolute inset-x-2 top-[max(0.5rem,env(safe-area-inset-top))] z-10 flex h-12 items-center justify-between gap-2 rounded-2xl border border-glass-line bg-glass px-3 md:top-4 md:right-[calc(var(--panel-w)+2rem)] md:left-24 md:backdrop-blur-xl">
|
|
{/* 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-title text-sm sm:inline">DEADHEAD</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
<span className="flex items-center gap-1.5 text-ink-300">
|
|
<span aria-label="Game clock" className="text-[13px] tabular-nums">
|
|
{clock}
|
|
</span>
|
|
</span>
|
|
<span aria-label="Credits" className="text-[15px] font-semibold tabular-nums">
|
|
{credits}
|
|
</span>
|
|
<Link
|
|
to="/settings"
|
|
aria-label="Settings"
|
|
className="grid size-9 place-items-center rounded-lg text-ink-300 transition hover:bg-white/8 hover:text-ink-50"
|
|
>
|
|
<Icon name="gear" className="size-5" />
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
{banner && (
|
|
<div className="absolute inset-x-2 top-16 z-10 overflow-hidden rounded-xl md:top-20 md:right-[calc(var(--panel-w)+2rem)] md:left-24">
|
|
{banner}
|
|
</div>
|
|
)}
|
|
|
|
<PanelHost>
|
|
<Outlet />
|
|
</PanelHost>
|
|
|
|
<nav
|
|
aria-label="Main"
|
|
className="absolute inset-x-0 bottom-0 z-10 flex h-(--nav-h) border-t border-glass-line bg-space-950/95 pb-[env(safe-area-inset-bottom)] md:top-4 md:right-auto md:bottom-4 md:left-4 md:h-auto md:w-16 md:flex-col md:gap-1 md:overflow-y-auto md:rounded-3xl md:border md:bg-glass md:py-2 md:backdrop-blur-xl"
|
|
>
|
|
{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 }) =>
|
|
`relative flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors md:flex-none md:py-2 ${
|
|
isActive ? "text-action" : "text-ink-300 hover:text-ink-50"
|
|
}`
|
|
}
|
|
>
|
|
<span className="relative grid size-6 place-items-center">
|
|
<Icon name={icon} className="size-5.5" />
|
|
{badge > 0 && (
|
|
<span
|
|
aria-hidden="true"
|
|
data-testid={`badge-${label}`}
|
|
className="absolute -top-1.5 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-problem px-1 text-xs leading-none font-bold text-on-problem"
|
|
>
|
|
{badge > 9 ? "9+" : badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{label}
|
|
</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>
|
|
);
|
|
}
|