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,315 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through
PATCH. Deliberately a flat list rather than a table: the message
is the content, and messages don't fit in a cell.
Every read passes ttl: 0. The api cache exists for public
content that changes weekly; a triage queue two people are
working at the same time is the opposite case.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { get, patch, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
const STATUS_STYLE = {
new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
spam: "bg-[#fdf3f2] text-[#b3261e]",
};
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
// won't parse without the T and the Z.
function formatDate(value) {
const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
function locationOf(row) {
if (!row.page_path) return "Not page-specific";
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
}
/* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, canWrite }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const noteDirty = note !== (row.admin_note ?? "");
async function save(changes) {
setBusy(true);
setError(null);
try {
const data = await patch(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
} finally {
setBusy(false);
}
}
return (
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<span className="font-semibold text-[#26454c]">
{feedbackTypeLabel(row.feedback_type)}
</span>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
STATUS_STYLE[row.status] ?? ""
}`}
>
{row.status}
</span>
<span className="text-[#4a6b72]">{locationOf(row)}</span>
<span className="ml-auto text-xs text-[#4a6b72]">
#{row.id} · {formatDate(row.created_at)}
</span>
</div>
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
<p className="mt-4 text-sm text-[#4a6b72]">
{row.name || row.email ? (
<>
{row.name && <span>{row.name}</span>}
{row.name && row.email && " · "}
{row.email && (
<a
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
className="text-[#138ba0] underline underline-offset-2"
>
{row.email}
</a>
)}
</>
) : (
<span className="italic">Sent anonymously</span>
)}
</p>
{canWrite && (
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor={`status-${row.id}`}
className="text-sm font-medium text-[#26454c]"
>
Status
</label>
<select
id={`status-${row.id}`}
value={row.status}
disabled={busy}
onChange={(e) => save({ status: e.target.value })}
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
</div>
<textarea
rows={2}
value={note}
disabled={busy}
placeholder="Internal note — who's handling it, what was done"
onChange={(e) => setNote(e.target.value)}
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
/>
{noteDirty && (
<button
type="button"
disabled={busy}
onClick={() => save({ admin_note: note })}
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{busy ? "Saving…" : "Save note"}
</button>
)}
</div>
)}
</article>
);
}
/* ── The page ────────────────────────────────────────────────── */
export default function AdminFeedback() {
const { user } = useAuth();
const navigate = useNavigate();
const [status, setStatus] = useState("new");
const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState([]);
const [counts, setCounts] = useState({});
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const canWrite = user?.role === "admin";
const load = useCallback(
async (before = null) => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (status !== "all") params.set("status", status);
if (search) params.set("q", search);
if (before) params.set("before", String(before));
try {
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
setCounts(data.counts);
setCursor(data.nextCursor);
} catch (err) {
if (isUnauthorized(err)) {
// Session expired while the page was open.
navigate("/admin/login", { replace: true });
return;
}
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
);
} finally {
setLoading(false);
}
},
[status, search, navigate],
);
useEffect(() => {
load();
}, [load]);
function replaceRow(updated) {
setRows((prev) =>
prev
.map((row) => (row.id === updated.id ? updated : row))
// A row that no longer matches the filter drops out, so
// marking something 'read' clears it from the 'new' queue.
.filter((row) => status === "all" || row.status === status),
);
setCounts((prev) => ({ ...prev })); // counts refresh on next load
}
const tabs = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
];
return (
<div>
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
<p className="mt-2 text-sm text-[#4a6b72]">
{canWrite
? "Everything submitted through the site form."
: "Read-only: your account can't change statuses or notes."}
</p>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-center gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatus(tab.id)}
className={
"rounded-full px-4 py-1.5 text-sm transition-colors " +
(status === tab.id
? "bg-[#138ba0] font-semibold text-white"
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
}
>
{tab.label}
{tab.count ? ` (${tab.count})` : ""}
</button>
))}
<div className="ml-auto flex gap-2">
<input
type="search"
value={query}
placeholder="Search messages"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") setSearch(query.trim());
}}
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
/>
<button
type="button"
onClick={() => setSearch(query.trim())}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
>
Search
</button>
</div>
</div>
{/* Results */}
{error && (
<p
role="alert"
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
{!loading && rows.length === 0 && !error && (
<p className="mt-10 text-[#4a6b72]">
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
</p>
)}
<div className="mt-6 space-y-4">
{rows.map((row) => (
<FeedbackCard
key={row.id}
row={row}
canWrite={canWrite}
onChange={replaceRow}
/>
))}
</div>
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
{cursor && !loading && (
<button
type="button"
onClick={() => load(cursor)}
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
Load older
</button>
)}
</div>
);
}

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>
);
}

View file

@ -0,0 +1,142 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN LOGIN
Deliberately outside PageShell and the site nav. This isn't a
page of the website; it's the door to the back office, and it
shouldn't carry a banner, a subnav, or a footer site map.
The Google button is stubbed and disabled until the OAuth
routes exist. It's here so the layout doesn't change when it
starts working.
═══════════════════════════════════════════════════════════════ */
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { ApiError } from "../../lib/api.js";
const GOOGLE_ENABLED = false;
const fieldClass =
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
export default function AdminLogin() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/feedback";
async function handleSubmit(event) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
await login(email.trim(), password);
navigate(destination, { replace: true });
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "Couldn't reach the server. Try again in a moment.",
);
setPassword("");
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#eef9fb] px-6 py-16">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold text-[#138ba0]">NGU admin</h1>
<p className="mt-2 text-sm text-[#4a6b72]">
Sign in to read and triage site feedback.
</p>
<form
onSubmit={handleSubmit}
noValidate
className="mt-8 rounded-2xl border border-[#138ba0]/20 bg-white p-6"
>
<label
htmlFor="admin-email"
className="block text-sm font-medium text-[#26454c]"
>
Email
</label>
<input
id="admin-email"
type="email"
autoComplete="username"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
<label
htmlFor="admin-password"
className="mt-5 block text-sm font-medium text-[#26454c]"
>
Password
</label>
<input
id="admin-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
{error && (
<p
role="alert"
className="mt-5 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
<button
type="submit"
disabled={busy || !email || !password}
className="mt-6 w-full rounded-full bg-[#138ba0] px-6 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25"
>
{busy ? "Signing in…" : "Sign in"}
</button>
{GOOGLE_ENABLED && (
<>
<div className="my-6 flex items-center gap-3 text-xs text-[#4a6b72]">
<span className="h-px flex-1 bg-[#4a6b72]/20" />
or
<span className="h-px flex-1 bg-[#4a6b72]/20" />
</div>
<a
href="/api/auth/google"
className="block rounded-full border border-[#4a6b72]/30 px-6 py-3 text-center font-semibold text-[#26454c] transition-colors hover:bg-[#eef9fb]"
>
Continue with Google
</a>
</>
)}
</form>
<p className="mt-6 text-center text-xs text-[#4a6b72]">
Accounts are created on the server. Ask whoever runs the box.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,422 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — ENTITY EDIT
Create and edit for every entity in the manifest. The form holds
the whole nested object — parent row, side tables, child
collections — and PATCH sends the lot. The server replaces
children wholesale, so what you see here is exactly what will
exist afterwards.
Nothing is written until Save, which is what makes removing a
repeater row safe: leaving without saving undoes it. That only
holds if leaving is hard to do by accident, hence the dirty
tracking and the two guards below.
A field the form never sets is left out of the payload entirely,
and the server lets the column's own DEFAULT apply. So a blank
new-record form is deliberate, not lazy — writing "" into every
field is what used to turn a default into a constraint failure.
updated_at rides along untouched. If someone else saved while
this page was open the server answers 409 rather than letting
one of you quietly overwrite the other. Entities with no
updated_at column simply never get one, and the 409 path stays
dormant for them.
slugFrom may name one field or several. Most ids are unique
because the name is: two organizations aren't both called
Northwest. Team ids are the exception — teams.id is a global
primary key, so every org's 'Board' would collide — which is
why the array form exists and teams uses it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { get, post, patch, del, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.tsx";
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
/* A foreign key refusing to budge is the most common way a save or
delete fails here, and SQLite's own wording explains nothing to
whoever is filling in the form. */
function friendly(message, singular) {
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
return `Something still points at this ${singular}. Reassign or remove those first.`;
}
return message;
}
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const isNew = id === "new";
const canWrite = user?.role === "admin";
// Hoisted above the loading guards: the title hook below is a
// hook, so it can't sit after an early return, and it needs the
// same paths the heading uses.
const slugPaths = Array.isArray(manifest?.slugFrom)
? manifest.slugFrom
: [manifest?.slugFrom].filter(Boolean);
// Everything before the last path is a qualifier: a fact about
// another field rather than something to type. It renders as
// fixed text inside the slug box, and the editable part is only
// what follows it.
const qualifierPaths = slugPaths.slice(0, -1);
// Empty until every qualifier is chosen, because half a prefix
// would be saved into an id that then never matches.
const prefixOf = (source) => {
if (qualifierPaths.length === 0) return "";
const parts = qualifierPaths.map((path) => getPath(source, path));
if (parts.some((part) => !part)) return "";
return `${slugify(parts.join(" "))}-`;
};
// What to show greyed out before then: org_id becomes "org-".
const prefixHint = qualifierPaths.length
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
: "";
const tailOf = (source) => {
const prefix = prefixOf(source);
const value = source?.id ?? "";
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
};
// The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board".
const headingPath = slugPaths[slugPaths.length - 1];
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
const [errors, setErrors] = useState({});
const [message, setMessage] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [slugTouched, setSlugTouched] = useState(false);
// The last state the server confirmed. Everything else compares
// against this to decide whether there's anything to lose.
const baseline = useRef(null);
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setErrors({});
try {
const opts = await get("/admin/options", { ttl: 60_000 });
setOptions(opts.options);
if (isNew) {
// Blank children arrays matter: an absent key means "don't
// touch", which is wrong for a row that doesn't exist yet.
// The parent's own fields stay absent on purpose so the
// server's column defaults apply to whatever isn't filled in.
const blank = { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
} else {
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
setForm(data.row);
baseline.current = JSON.stringify(data.row);
}
setMessage(null);
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setMessage({
tone: "error",
text: err instanceof ApiError ? err.message : "Couldn't load that.",
});
} finally {
setLoading(false);
}
}, [manifest, id, isNew, navigate]);
useEffect(() => {
load();
}, [load]);
const dirty = useMemo(
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
[form],
);
// The tab says what's on screen; the layout adds the section and
// the site name. Null while loading, so it reads "Teams | NGU
// Admin CMS" for the half-second before the record arrives
// rather than flashing a slug.
useAdminDetail(
!manifest
? null
: isNew
? `New ${manifest.singular}`
: form
? getPath(form, headingPath) || form.id
: null,
);
// Closing the tab or hitting the browser back button skips React
// Router entirely, so the only hook available is this one.
useEffect(() => {
if (!dirty) return undefined;
const warn = (event) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
/* ── Heading ───────────────────────────────────────────────── */
const heading = getPath(form, headingPath) || form.id;
const children = manifest.children ?? [];
/* ── Actions ───────────────────────────────────────────────── */
const leave = (to) => {
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
navigate(to);
};
const change = (path, value) => {
setForm((prev) => {
let next = setPath(prev, path, value);
// Recompose the id whenever one of its sources moves. The
// prefix always follows the organization — picking a
// different one has to change the slug, or it would claim a
// team belongs somewhere it doesn't. The tail only follows
// the name until someone types over it.
if (isNew && slugPaths.includes(path)) {
const tail = slugTouched
? tailOf(prev)
: slugify(getPath(next, headingPath) ?? "");
next = { ...next, id: `${prefixOf(next)}${tail}` };
}
return next;
});
// Clear this field's error as soon as it's touched; leaving a
// stale red outline on a field the user just fixed reads as a
// save that didn't take.
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
};
async function save() {
setSaving(true);
setErrors({});
setMessage(null);
try {
const data = isNew
? await post(`/admin/${manifest.key}`, form)
: await patch(`/admin/${manifest.key}/${id}`, form);
setForm(data.row);
baseline.current = JSON.stringify(data.row);
setMessage({ tone: "ok", text: "Saved." });
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
if (err instanceof ApiError) {
// 409 means the updated_at we're holding is stale. Every
// further save will fail the same way until the page is
// reloaded, so offer that rather than just saying no.
if (err.status === 409) {
setMessage({ tone: "error", text: err.message, recover: "reload" });
} else {
setErrors(err.fields ?? {});
setMessage({
tone: "error",
text: err.fields
? "Some fields need attention."
: friendly(err.message, manifest.singular),
});
}
} else {
setMessage({ tone: "error", text: "Couldn't reach the server." });
}
} finally {
setSaving(false);
}
}
async function remove() {
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
try {
await del(`/admin/${manifest.key}/${id}`);
baseline.current = JSON.stringify(form); // nothing left to warn about
navigate(`/admin/${manifest.key}`, { replace: true });
} catch (err) {
setMessage({
tone: "error",
text:
err instanceof ApiError
? friendly(err.message, manifest.singular)
: "Couldn't delete that.",
});
}
}
const visible = (when) => !when || getPath(form, when.path) === when.value;
return (
<div className="pb-24">
<button
type="button"
onClick={() => leave(`/admin/${manifest.key}`)}
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
>
← {manifest.label}
</button>
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* Slug */}
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
path: "id",
label: manifest.idLabel,
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
prefixPending: isNew && !prefixOf(form),
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
readOnly: !isNew,
help: !isNew
? "Fixed once created — links and content blocks reference it."
: qualifierPaths.length
? "The prefix comes from the organization. Type the rest."
: "Lowercase, hyphens, no spaces. Can't be changed later.",
}}
value={isNew ? tailOf(form) : form.id}
error={errors.id}
onChange={(value) => {
setSlugTouched(true);
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && form.updated_at && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {form.updated_at}
</p>
)}
</div>
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
<section
key={group.legend}
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
>
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
<div className="mt-4">
<FieldGrid>
{group.fields.map((field) => (
<Field
key={field.path}
field={field}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
onChange={(value) => change(field.path, value)}
/>
))}
</FieldGrid>
</div>
</section>
))}
{/* Child collections. Awards own none, so the panel would be
an empty white box — skip it rather than render it. */}
{children.some((child) => visible(child.when)) && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
{children
.filter((child) => visible(child.when))
.map((child) => (
<Repeater
key={child.key}
spec={child}
rows={form[child.key]}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
/>
))}
</div>
)}
{/* Sticky action bar */}
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
{canWrite ? (
<>
<button
type="button"
onClick={save}
disabled={saving || !dirty}
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && (
<button
type="button"
onClick={remove}
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes.
</span>
)}
{dirty && !saving && (
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
)}
{message && (
<span
role="status"
className={`flex items-center gap-2 text-sm ${
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
}`}
>
{message.text}
{message.recover === "reload" && (
<button
type="button"
onClick={load}
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
>
Reload
</button>
)}
</span>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,181 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — ENTITY LIST
One component for organizations, events and people. The :entity
route param picks the manifest; nothing here knows what a
chapter or a retreat is.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
export default function EntityList() {
const { entity: entityKey } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const [rows, setRows] = useState([]);
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [query, setQuery] = useState(params.get("q") ?? "");
const canWrite = user?.role === "admin";
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setError(null);
try {
const [list, opts] = await Promise.all([
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get("/admin/options", { ttl: 60_000 }),
]);
setRows(list.rows);
setOptions(opts.options);
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError(err instanceof ApiError ? err.message : "Couldn't reach the server.");
} finally {
setLoading(false);
}
}, [manifest, params, navigate]);
useEffect(() => {
load();
}, [load]);
if (!manifest) {
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
}
function setParam(key, value) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
}
function labelFor(filter, value) {
if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
}
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
}
return (
<div>
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-3xl font-bold text-[#138ba0]">{manifest.label}</h1>
{canWrite && (
<Link
to={`/admin/${manifest.key}/new`}
className="ml-auto rounded-full bg-[#138ba0] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183]"
>
New {manifest.singular}
</Link>
)}
</div>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-end gap-3">
{manifest.list.filters.map((filter) => (
<label key={filter.key} className="text-xs text-[#4a6b72]">
<span className="block">{filter.label}</span>
<select
value={params.get(filter.key) ?? ""}
onChange={(e) => setParam(filter.key, e.target.value)}
className="mt-1 rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
>
<option value="">All</option>
{labelFor(filter).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
))}
<div className="ml-auto flex gap-2">
<input
type="search"
value={query}
placeholder="Search"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParam("q", query.trim())}
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
/>
<button
type="button"
onClick={() => setParam("q", query.trim())}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] hover:border-[#138ba0]/50"
>
Search
</button>
</div>
</div>
{error && (
<p
role="alert"
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
{/* Rows */}
<div className="mt-6 overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
<table className="w-full text-left text-sm">
<thead className="border-b border-[#4a6b72]/15 text-xs text-[#4a6b72]">
<tr>
{manifest.list.columns.map((column) => (
<th key={column.key} className="px-4 py-3 font-medium">
{column.label}
</th>
))}
<th className="px-4 py-3 font-medium">Slug</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
>
{manifest.list.columns.map((column) => (
<td
key={column.key}
className={`px-4 py-3 ${
column.primary ? "font-medium text-[#26454c]" : "text-[#4a6b72]"
}`}
>
{column.widget === "bool"
? row[column.key]
? "Yes"
: "—"
: row[column.key] || "—"}
</td>
))}
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
</tr>
))}
</tbody>
</table>
{!loading && rows.length === 0 && (
<p className="px-4 py-8 text-center text-[#4a6b72]">Nothing matches.</p>
)}
{loading && <p className="px-4 py-8 text-center text-[#4a6b72]">Loading…</p>}
</div>
</div>
);
}