v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue