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>
180 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|