v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -5,59 +5,45 @@
footer site map — none of that belongs around a staff tool, and
/admin should never appear in navConfig.
The nav is two tiers, the same shape as the public header: a
primary row of the things you'd go looking for, and a subnav of
whatever sits under the one you're in. Teams and Awards live
under Organizations because that's where they belong
conceptually — a team is part of an org, an award is given by
one — even though each is its own table and its own page.
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.
NAV is the single source for the rows, the document title and
which tab lights up. Adding an entity is an entry here plus a
descriptor; there's no second list to keep in step.
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 { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
const SITE_TITLE = "NGU Admin CMS";
// A group with no `to` of its own opens its first child, so
// clicking the word Forms goes somewhere rather than nowhere.
const NAV = [
{ to: "/admin/events", label: "Events" },
{
to: "/admin/organizations",
label: "Organizations",
children: [
{ to: "/admin/organizations", label: "All organizations" },
{ to: "/admin/teams", label: "Teams" },
{ to: "/admin/awards", label: "Awards" },
],
},
{ to: "/admin/people", label: "People" },
{
label: "Forms",
separated: true,
children: [{ to: "/admin/feedback", label: "Website feedback" }],
},
];
// A tab owns its own page and everything below it, so editing
// /admin/teams/ngu-board keeps Teams lit.
const matches = (pathname, to) =>
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
const target = (item) => item.to ?? item.children?.[0]?.to;
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.js";
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const active = NAV.find(
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)),
@ -75,9 +61,12 @@ export default function AdminLayout() {
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {
const section = activeChild?.label ?? active?.label;
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
}, [active, activeChild, detail]);
// 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();
@ -86,37 +75,54 @@ export default function AdminLayout() {
const subnav = active?.children ?? [];
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
return (
<div className="min-h-screen bg-[#f6fbfc]">
<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">
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
{/* 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>
)}
<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>
{!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>
<span>
{user?.name || user?.email}
</span>
<button
type="button"
onClick={handleLogout}
@ -150,11 +156,25 @@ export default function AdminLayout() {
)}
</header>
<main className="mx-auto max-w-5xl px-6 py-10">
{/* 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>
);
}