NGU-Web/src/pages/admin/AdminLayout.tsx
Zaldimmar 22d5328885 Convert src/ JavaScript modules to TypeScript
Renames every .js module under src/ to .ts (api, useResource,
adminSchema, navConfig, adminNav and src/data/*) and points imports,
comments and the seed script's module paths at the new names. Their
types now come from inference; no .d.ts files and no shape
interfaces.

tsc stays strict (noImplicitAny off). The errors inference leaves
behind get the lightest fix that clears them: `any` on empty state,
contexts and list defaults, `: any` on components with optional or
spread props, class fields on ApiError, and option shapes on
get/useResource.

Kept as real types, since they belong to modules that were already
TypeScript and later features import them: PageShell's ShellSection
and props, useContent's corrected record types (website/email/
instagram are bare strings) and EventListItem, and TimelineRef's
orgKind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:39:42 -05:00

180 lines
6.6 KiB
TypeScript

/* ═══════════════════════════════════════════════════════════════
ADMIN LAYOUT
Bare on purpose. No PageShell, no announcement banner, no
footer site map — none of that belongs around a staff tool, and
/admin should never appear in navConfig.
Three areas share this chrome: Home, the CMS and the Panel. The
wordmark names whichever one you're in, and from anywhere but
Home it's the way back to Home.
The tab row is drawn everywhere except Home, where the card grid
is the navigation and drawing both would say the same thing
twice. Which tabs appear depends on the signed-in user —
navFor() drops the superadmin-only ones — but that's cosmetics.
The route guard and the API are what actually say no.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import nguLogo from "../../assets/NGU_Logo.svg";
import {
ADMIN_HOME,
AREA_TITLES,
areaFor,
matches,
navFor,
target,
} from "./adminNav.ts";
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const area = areaFor(pathname);
const areaTitle = AREA_TITLES[area];
const isHome = area === "home";
const nav = useMemo(() => navFor(user), [user]);
const active = nav.find(
(item) =>
matches(pathname, item.to) ||
(item.children ?? []).some((child) => matches(pathname, child.to)),
);
const activeChild = (active?.children ?? []).find((child) =>
matches(pathname, child.to),
);
// What the page below has published about itself — a record
// name, or null on a list. setDetail is stable so publishing
// can't loop.
const [detail, setDetail] = useState<any>(null);
const stableSet = useCallback((value) => setDetail(value), []);
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {
// Sections only exist in the CMS. Home and Panel already say
// what they are in the area title; "Panel | NGU Admin Panel"
// would just stutter.
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
}, [area, areaTitle, active, activeChild, detail]);
async function handleLogout() {
await logout();
navigate("/admin/login", { replace: true });
}
const subnav = active?.children ?? [];
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
return (
<div className="flex min-h-screen flex-col bg-[#f6fbfc]">
<header className="border-b border-[#138ba0]/20 bg-white">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
{/* Already home, so nothing to link to. */}
{isHome ? (
wordmark
) : (
<Link
to={ADMIN_HOME}
className="rounded transition-opacity hover:opacity-70"
title="Back to the admin home"
>
{wordmark}
</Link>
)}
{!isHome && (
<nav className="flex items-center gap-6 text-sm">
{nav.map((item) => (
<div key={item.label} className="flex items-center gap-6">
{item.separated && (
<span
aria-hidden="true"
className="h-4 w-px bg-[#4a6b72]/25"
/>
)}
<NavLink
to={target(item)}
className={
item === active
? "font-semibold text-[#138ba0]"
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
}
>
{item.label}
</NavLink>
</div>
))}
</nav>
)}
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
<span>
{user?.name || user?.email}
</span>
<button
type="button"
onClick={handleLogout}
className="rounded-full border border-[#4a6b72]/30 px-4 py-1.5 font-medium transition-colors hover:bg-[#eef9fb] hover:text-[#138ba0]"
>
Sign out
</button>
</div>
</div>
{/* Subnav. Only drawn where there is something to draw, so
Events and People don't get an empty grey strip. */}
{subnav.length > 0 && (
<div className="border-t border-[#138ba0]/10 bg-[#f6fbfc]">
<div className="mx-auto flex max-w-5xl flex-wrap gap-6 px-6 py-2.5 text-sm">
{subnav.map((child) => (
<NavLink
key={child.to}
to={child.to}
className={
child === activeChild
? "font-semibold text-[#138ba0]"
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
}
>
{child.label}
</NavLink>
))}
</div>
</div>
)}
</header>
{/* flex-1 rather than a fixed height: the footer sits at the
bottom of a short page and below the content of a long one,
without ever floating over it. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
<AdminTitleContext.Provider value={titleContext}>
<Outlet />
</AdminTitleContext.Provider>
</main>
<footer className="border-t border-[#138ba0]/15 bg-white">
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
</Link>
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
{SITE_VERSION}
</span>
</div>
</footer>
</div>
);
}