v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

View file

@ -0,0 +1,160 @@
/* ═══════════════════════════════════════════════════════════════
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.
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.
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.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react";
import { 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;
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
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(null);
const stableSet = useCallback((value) => setDetail(value), []);
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]);
async function handleLogout() {
await logout();
navigate("/admin/login", { replace: true });
}
const subnav = active?.children ?? [];
return (
<div className="min-h-screen 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>
<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>
<main className="mx-auto max-w-5xl px-6 py-10">
<AdminTitleContext.Provider value={titleContext}>
<Outlet />
</AdminTitleContext.Provider>
</main>
</div>
);
}