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

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>
);
}