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

@ -1,17 +1,31 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
/*Components*/
import Layout from "./components/Layout.tsx";
/*Libraries*/
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
/*Primary Pages*/
import Home from "./pages/Home.tsx";
import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx";
/*Secondary Pages*/
import Feedback from "./pages/Feedback.tsx";
import Giving from "./pages/Giving.tsx";
/*Admin Pages*/
import AdminLayout from "./pages/admin/AdminLayout.tsx";
import AdminLogin from "./pages/admin/AdminLogin.tsx";
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
import EntityList from "./pages/admin/EntityList.tsx";
import EntityEdit from "./pages/admin/EntityEdit.tsx";
/*Ternary Pages*/
import Privacy from "./pages/Privacy.tsx";
import Terms from "./pages/Terms.tsx";
import NotFound from "./pages/NotFound.tsx";
export default function App() {
@ -31,6 +45,17 @@ export default function App() {
<Route path="terms" element={<Terms />} />
<Route path="*" element={<NotFound />} />
</Route>
<Route element={<AuthProvider><Outlet /></AuthProvider>}>
<Route path="/admin/login" element={<AdminLogin />} />
<Route element={<RequireAuth />}>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/feedback" replace />} />
<Route path="feedback" element={<AdminFeedback />} />
<Route path=":entity" element={<EntityList />} />
<Route path=":entity/:id" element={<EntityEdit />} />
</Route>
</Route>
</Route>
</Routes>
</BrowserRouter>
);

View file

@ -1,48 +1,370 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
} from "react";
import { get } from "../lib/api.js";
import "./PeopleTiles.css";
/**
* PeopleTiles — a horizontal, polaroid-style people list.
*
* Drop into any section:
* <PeopleTiles size="md" groups={staffGroups} />
* <PeopleTiles size="sm" people={volunteers} />
* Supply data any of four ways:
*
* <PeopleTiles size="lg" teams="ngu-board" />
* <PeopleTiles size="md" teams={["ngu-board", "nw-leadership"]} />
* <PeopleTiles size="sm" people={[
* { peopleslug: "john-doe", title: "Hospitality" },
* { peopleslug: "jane-doe" },
* ]} />
* <PeopleTiles size="sm" people={volunteers} overflow="scroll" />
*
* With `teams`, each team is fetched in the order given and split
* into a lead group and a members group by the affiliation's
* is_owner flag.
*
* With `peopleslug`, the person is fetched by id and any other key
* on the entry overrides what came back — so a title can be given
* per placement, and is blank when it isn't. A slug entry can sit
* beside a fully-written person in the same array.
*
* Sizes
* sm photo + name
* md photo + name + title
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
* lg photo + name + title, and a chevron on anyone who has
* something to expand: pronouns, home organization, location,
* public email or a bio. Not bio alone — the details panel is
* worth opening before anyone has written prose.
*
* Group shape
* { id, label?, note?, accent?, people: [person] }
*
* Person shape
* {
* id, name,
* title?, // "Regional Director"
* photo?, // "/people/jane-doe.jpg" — falls back to initials
* pronouns?, // "she/her"
* age?, // number, or use birthdate
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
* org?, // "Grace Chapel" or { name, href }
* bio?, // string or string[] (paragraphs)
* accent?, // per-person override
* }
* Field names follow the API (is_owner, location_label), so a row
* from /api/teams/:id/people drops in unchanged.
*/
const SIZE_FEATURES = {
sm: { title: false, bio: false },
md: { title: true, bio: false },
lg: { title: true, bio: true },
export interface Person {
id?: string | number;
name: string;
title?: string | null;
tagline?: string | null;
photo?: string | null;
pronouns?: string | null;
location_label?: string | null;
public_email?: string | null;
org?: string | { id?: string; name: string; href?: string } | null;
bio?: string | string[] | null;
accent?: string;
role?: string | null;
is_owner?: boolean;
/** Accepted as an alias so hand-written entries can use either. */
isOwner?: boolean;
}
/** A person named by slug. Any other field overrides the record. */
export interface PersonRef extends Partial<Omit<Person, "name">> {
peopleslug: string;
name?: string;
}
export type PersonInput = Person | PersonRef;
export interface PeopleGroup {
id?: string;
label?: string;
note?: string;
accent?: string;
people: Person[];
}
export interface PeopleGroupInput extends Omit<PeopleGroup, "people"> {
people: PersonInput[];
}
export interface TeamSpec {
id: string;
/** Heading for the members group. Defaults to the team's name. */
label?: string;
/** Heading for the lead group. Defaults to the lead's own title. */
leadLabel?: string;
/** Set false to keep owners inline with everyone else. */
splitOwners?: boolean;
accent?: string;
}
export type TeamSource = string | TeamSpec;
export type PeopleTilesSize = "sm" | "md" | "lg";
export interface PeopleTilesProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
people?: PersonInput[];
groups?: PeopleGroupInput[];
teams?: TeamSource | TeamSource[];
size?: PeopleTilesSize;
scale?: number;
overflow?: "wrap" | "scroll";
align?: "start" | "center";
accent?: string;
tilt?: boolean;
/** How long a fetched team or person is reused, in ms. */
ttl?: number;
emptyMessage?: string;
loadingMessage?: string;
errorMessage?: string;
onExpand?: (person: Person | null, group: PeopleGroup | null) => void;
}
const SIZE_FEATURES: Record<PeopleTilesSize, { title: boolean; details: boolean }> = {
sm: { title: false, details: false },
md: { title: true, details: false },
lg: { title: true, details: true },
};
/* ── Data ──────────────────────────────────────────────────────
Fetching lives here rather than in every section, but the view
below stays pure — it only ever sees resolved groups, whatever
produced them.
───────────────────────────────────────────────────────────── */
export default function PeopleTiles({
teams,
groups,
people,
ttl = 5 * 60_000,
loadingMessage = "Loading…",
errorMessage = "Couldn't load this list right now.",
...view
}: PeopleTilesProps) {
const specs = useMemo(() => normalizeTeams(teams), [teams]);
const teamKey = specs.map((spec) => spec.id).join(",");
// Sorted so two sections naming the same people in a different
// order still hit the same cached request.
const slugs = useMemo(
() => (specs.length ? [] : collectSlugs(groups, people)),
[specs.length, groups, people],
);
const slugKey = slugs.join(",");
const [teamGroups, setTeamGroups] = useState<PeopleGroup[] | null>(null);
const [directory, setDirectory] = useState<Record<string, Person> | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!specs.length) {
setTeamGroups(null);
return undefined;
}
let live = true;
setFailed(false);
Promise.all(
specs.map((spec) =>
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
spec,
data,
})),
),
)
.then((results) => {
if (!live) return;
// Order follows the order the teams were supplied in, not
// whichever request came back first.
setTeamGroups(
results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)),
);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load teams", teamKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [teamKey, ttl, specs]);
useEffect(() => {
if (!slugKey) {
setDirectory(null);
return undefined;
}
let live = true;
setFailed(false);
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data: { people: Person[] }) => {
if (!live) return;
const byId: Record<string, Person> = {};
for (const person of data.people) byId[String(person.id)] = person;
setDirectory(byId);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load people", slugKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [slugKey, ttl]);
if (specs.length) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!teamGroups) return <p className="pl__empty">{loadingMessage}</p>;
return <PeopleTilesView {...view} groups={teamGroups} />;
}
if (slugKey) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!directory) return <p className="pl__empty">{loadingMessage}</p>;
return (
<PeopleTilesView
{...view}
groups={groups?.map((group) => ({
...group,
people: resolveAll(group.people, directory),
}))}
people={people ? resolveAll(people, directory) : undefined}
/>
);
}
return (
<PeopleTilesView
{...view}
groups={groups as PeopleGroup[] | undefined}
people={people as Person[] | undefined}
/>
);
}
interface TeamResponse {
team: { id: string; name: string; color?: string | null };
people: Person[];
}
function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] {
if (!teams) return [];
const list = Array.isArray(teams) ? teams : [teams];
return list
.map((entry) => (typeof entry === "string" ? { id: entry } : entry))
.filter((spec): spec is TeamSpec => Boolean(spec?.id));
}
function isRef(entry: PersonInput): entry is PersonRef {
return typeof (entry as PersonRef).peopleslug === "string";
}
function collectSlugs(
groups?: PeopleGroupInput[],
people?: PersonInput[],
): string[] {
const found = new Set<string>();
const scan = (list?: PersonInput[]) => {
for (const entry of list ?? []) {
if (entry && isRef(entry)) found.add(entry.peopleslug);
}
};
scan(people);
for (const group of groups ?? []) scan(group.people);
return [...found].sort();
}
/* The fetched record is the base; anything else on the entry wins,
including an explicit null — that's how a title is deliberately
left blank rather than inherited. */
function resolveAll(
list: PersonInput[],
directory: Record<string, Person>,
): Person[] {
const resolved: Person[] = [];
for (const entry of list) {
if (!entry) continue;
if (!isRef(entry)) {
resolved.push(entry);
continue;
}
const base = directory[entry.peopleslug];
if (!base) {
// Unpublished, deleted, or a typo in the slug. Leaving the
// tile out beats rendering a nameless placeholder.
console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`);
continue;
}
const { peopleslug, ...overrides } = entry;
const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
}
resolved.push(merged);
}
return resolved;
}
function buildTeamGroups(
spec: TeamSpec,
data: TeamResponse,
teamCount: number,
): PeopleGroup[] {
const accent = spec.accent ?? data.team.color ?? undefined;
const name = spec.label ?? data.team.name;
const split = spec.splitOwners !== false;
const owners = split ? data.people.filter(owns) : [];
const rest = split ? data.people.filter((person) => !owns(person)) : data.people;
// A lead only reads as a lead when there's a body of people to
// stand apart from. All owners, or none, is just a list.
if (!owners.length || !rest.length) {
return [
{
id: data.team.id,
// One unlabelled team needs no heading; several always do.
label: teamCount > 1 || spec.label ? name : undefined,
accent,
people: data.people,
},
];
}
return [
{
id: `${data.team.id}-lead`,
label: spec.leadLabel ?? leadLabel(owners, name),
accent,
people: owners,
},
{ id: data.team.id, label: name, accent, people: rest },
];
}
function leadLabel(owners: Person[], teamName: string): string {
if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string;
return `${teamName} lead${owners.length > 1 ? "s" : ""}`;
}
/* ── View ────────────────────────────────────────────────────── */
export function PeopleTilesView({
people,
groups,
size = "md",
scale = 1,
overflow = "wrap", // "wrap" | "scroll"
align = "start", // "start" | "center"
overflow = "wrap",
align = "start",
accent,
tilt = false,
emptyMessage = "No one listed yet.",
@ -50,18 +372,24 @@ export default function PeopleTiles({
className = "",
style,
...rest
}: Omit<
PeopleTilesProps,
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
> & {
people?: Person[];
groups?: PeopleGroup[];
}) {
const baseId = useId().replace(/:/g, "");
const [openKey, setOpenKey] = useState(null);
const rootRef = useRef(null);
const [openKey, setOpenKey] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
const features = SIZE_FEATURES[resolvedSize];
const resolvedGroups = useMemo(() => {
const source = Array.isArray(groups) && groups.length
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
const source = groups?.length
? groups
: Array.isArray(people) && people.length
: people?.length
? [{ id: "all", people }]
: [];
@ -74,7 +402,7 @@ export default function PeopleTiles({
.filter((group) => group.people.length > 0);
}, [groups, people]);
// Close the bio if the person it belongs to disappears from the data.
// Close the panel if the person it belongs to disappears.
useEffect(() => {
if (!openKey) return;
const stillThere = resolvedGroups.some((group) =>
@ -87,21 +415,23 @@ export default function PeopleTiles({
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
}
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
function toggle(group, person, index) {
function toggle(group: PeopleGroup, person: Person, index: number) {
const key = keyFor(group, person, index);
const next = openKey === key ? null : key;
setOpenKey(next);
if (onExpand) onExpand(next ? person : null, next ? group : null);
onExpand?.(next ? person : null, next ? group : null);
}
function handleKeyDown(event) {
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && openKey) {
event.stopPropagation();
setOpenKey(null);
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
if (button) button.focus();
const button = rootRef.current?.querySelector<HTMLButtonElement>(
'.pl__tile[aria-expanded="true"]',
);
button?.focus();
}
}
@ -113,11 +443,13 @@ export default function PeopleTiles({
data-overflow={overflow}
data-align={align}
data-tilt={tilt ? "on" : "off"}
style={{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
}}
style={
{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
} as CSSProperties
}
onKeyDown={handleKeyDown}
{...rest}
>
@ -126,7 +458,9 @@ export default function PeopleTiles({
<section
key={group.id}
className="pl__group"
style={group.accent ? { "--pl-accent": group.accent } : undefined}
style={
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
}
aria-label={group.label || undefined}
>
{(group.label || group.note) && (
@ -139,14 +473,18 @@ export default function PeopleTiles({
<ul className="pl__row">
{group.people.map((person, index) => {
const key = keyFor(group, person, index);
const expandable = features.bio && hasBio(person);
const expandable = features.details && hasDetails(person);
const isOpen = expandable && openKey === key;
return (
<li
key={key}
className="pl__item"
style={person.accent ? { "--pl-accent": person.accent } : undefined}
style={
person.accent
? ({ "--pl-accent": person.accent } as CSSProperties)
: undefined
}
>
<Tile
person={person}
@ -165,13 +503,13 @@ export default function PeopleTiles({
</div>
{open && (
<BioPanel
<DetailPanel
id={`${baseId}-bio`}
person={open.person}
group={open.group}
onClose={() => {
setOpenKey(null);
if (onExpand) onExpand(null, null);
onExpand?.(null, null);
}}
/>
)}
@ -179,24 +517,38 @@ export default function PeopleTiles({
);
}
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
function Tile({
person,
showTitle,
expandable,
isOpen,
panelId,
onToggle,
}: {
person: Person;
showTitle: boolean;
expandable: boolean;
isOpen: boolean;
panelId: string;
onToggle: () => void;
}) {
const title = titleOf(person);
const content = (
<>
<span className="pl__frame">
<span className="pl__photo">
<Photo src={person.photo} name={person.name} />
</span>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
<span className="pl__frame">
<span className="pl__photo">
<Photo src={photoSrc(person.photo)} name={person.name} />
</span>
</>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && title && <span className="pl__title">{title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
</span>
);
if (!expandable) {
@ -212,12 +564,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
onClick={onToggle}
>
{content}
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
</button>
);
}
function Photo({ src, name }) {
function Photo({ src, name }: { src?: string | null; name: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
@ -244,10 +596,21 @@ function Photo({ src, name }) {
);
}
function BioPanel({ id, person, group, onClose }) {
const age = resolveAge(person);
function DetailPanel({
id,
person,
group,
onClose,
}: {
id: string;
person: Person;
group: PeopleGroup;
onClose: () => void;
}) {
const org = resolveOrg(person.org);
const title = titleOf(person);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
const tint = person.accent || group?.accent;
return (
<div
@ -255,49 +618,55 @@ function BioPanel({ id, person, group, onClose }) {
className="pl__bio"
role="region"
aria-label={`About ${person.name}`}
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
>
<div className="pl__bio-head">
<div>
<p className="pl__bio-name">{person.name}</p>
{person.title && <p className="pl__bio-title">{person.title}</p>}
{title && <p className="pl__bio-title">{title}</p>}
</div>
<button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close bio</span>
<span className="pl__sr">Close details</span>
<span aria-hidden="true">×</span>
</button>
</div>
{(person.pronouns || age != null || org) && (
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{age != null && (
<div className="pl__fact">
<dt>Age</dt>
<dd>{age}</dd>
</div>
)}
{org && (
<div className="pl__fact">
<dt>Home organization</dt>
<dd>
{org.href ? (
<a href={org.href} target="_blank" rel="noreferrer">
{org.name}
</a>
) : (
org.name
)}
</dd>
</div>
)}
</dl>
)}
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{org && (
<div className="pl__fact">
<dt>Home organization</dt>
<dd>
{org.href ? (
<a href={org.href} target="_blank" rel="noreferrer">
{org.name}
</a>
) : (
org.name
)}
</dd>
</div>
)}
{person.location_label && (
<div className="pl__fact">
<dt>Based in</dt>
<dd>{person.location_label}</dd>
</div>
)}
{person.public_email && (
<div className="pl__fact">
<dt>Email</dt>
<dd>
<a href={`mailto:${person.public_email}`}>{person.public_email}</a>
</dd>
</div>
)}
</dl>
{paragraphs.filter(Boolean).map((paragraph, index) => (
<p key={index} className="pl__bio-text">
@ -323,13 +692,13 @@ function Chevron() {
);
}
/* helpers ------------------------------------------------------------- */
/* ── Helpers ─────────────────────────────────────────────────── */
function keyFor(group, person, index) {
function keyFor(group: PeopleGroup, person: Person, index: number): string {
return `${group.id}:${person.id ?? person.name ?? index}`;
}
function findByKey(groups, key) {
function findByKey(groups: PeopleGroup[], key: string | null) {
if (!key) return null;
for (const group of groups) {
for (let index = 0; index < group.people.length; index += 1) {
@ -340,12 +709,36 @@ function findByKey(groups, key) {
return null;
}
function hasBio(person) {
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
return Boolean(person.bio);
/* The seat they hold here, or what they're called when there is no
seat — the same fallback content.js applies to leadership rows. */
function titleOf(person: Person): string | null {
return person.title || person.tagline || null;
}
function initials(name = "") {
function owns(person: Person): boolean {
return Boolean(person.is_owner ?? person.isOwner);
}
/* Anything the panel would have to show. Gating on bio alone left
every tile flat until someone wrote prose. */
function hasDetails(person: Person): boolean {
if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) {
return true;
}
return Boolean(
person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email,
);
}
/* Database rows carry a bare filename; a hand-written entry may
give a path or a full URL. Both should work. */
function photoSrc(photo?: string | null): string | null {
if (!photo) return null;
if (/^(https?:|\/|data:)/.test(photo)) return photo;
return `/people/${photo}`;
}
function initials(name = ""): string {
return name
.trim()
.split(/\s+/)
@ -355,21 +748,9 @@ function initials(name = "") {
.toUpperCase();
}
function resolveAge(person) {
if (typeof person.age === "number") return person.age;
if (!person.birthdate) return null;
const born = new Date(person.birthdate);
if (Number.isNaN(born.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - born.getFullYear();
const monthDelta = now.getMonth() - born.getMonth();
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
return age >= 0 ? age : null;
}
function resolveOrg(org) {
function resolveOrg(org: Person["org"]) {
if (!org) return null;
if (typeof org === "string") return { name: org };
if (typeof org === "string") return { name: org, href: undefined };
if (!org.name) return null;
return org;
}

View file

@ -0,0 +1,418 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN FORM PRIMITIVES
Field renders one input from a manifest entry. Repeater renders
an ordered collection of them, and nests one level for content
blocks and their items.
Ordering is array position — the server writes sort_order from
the index — so moving a row is a splice, not a number to
hand-edit. Rows can be dragged by the handle or moved with the
arrow buttons; the arrows are the keyboard path and stay whether
or not a pointer is in use.
A row added and never filled in is dropped by the server rather
than rejected, which depends on spec.blank seeding nothing the
server doesn't also declare as a column default. If you give a
blank row a starting value here, add the matching default: to
that column in the server's admin-schema.js or the row will be
saved as real input.
Two options change the box itself rather than what goes in it:
prefix fixed text inside the box, left of the cursor. The
value it decorates is only the part after it, so
the caller joins the two. Used by composed slugs,
where the prefix is a fact about another field
rather than something to retype.
readOnly shown, selectable, not editable. Deliberately not
`disabled`: a disabled control reads as switched
off and drops out of the tab order, whereas an
immutable id is settled fact you still want to be
able to read and copy.
═══════════════════════════════════════════════════════════════ */
import { useRef, useState } from "react";
const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
/* Same box, but lit by the real input nested inside it. */
const inputShell =
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
"focus-within:ring-[#138ba0]/25";
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
const shellError =
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
/* Reads as settled fact rather than as an empty box someone forgot
to fill in. */
const inputLocked =
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
/* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) {
return path.split(".").reduce((value, key) => value?.[key], object);
}
export function setPath(object, path, value) {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
let list = null;
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
: (field.options ?? []).map((o) =>
Array.isArray(o) ? [o[0], o[1]] : [o, o],
);
if (field.filterBy && row) {
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
}
// A stored value with no matching option renders as the blank
// choice, which reads as "nobody set this" and saves as a
// deliberate clear. It usually means the row it pointed at was
// deleted, so keep it on screen and say so.
orphaned =
value != null &&
value !== "" &&
!list.some(([optionId]) => String(optionId) === String(value));
}
const common = {
id,
className: `${input} ${error ? inputError : ""}`,
value: value ?? "",
onChange: (e) => onChange(e.target.value),
};
return (
<div className={field.full ? "sm:col-span-2" : ""}>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
{field.label}
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
</label>
<div className="mt-1.5">
{widget === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
<input
id={id}
type="checkbox"
checked={value === 1 || value === true}
disabled={locked}
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
/>
{field.help ?? "Yes"}
</label>
) : widget === "textarea" ? (
<textarea
{...common}
rows={4}
readOnly={locked}
className={`${locked ? inputLocked : common.className} resize-y`}
/>
) : widget === "select" ? (
// A select has no readOnly, so this one really does have
// to be disabled — there's no way to keep it focusable
// and still refuse a new choice.
<select
{...common}
disabled={locked}
className={`${locked ? inputLocked : common.className} ${
orphaned && !locked ? inputError : ""
}`}
>
<option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={value}>{value} — no longer exists</option>}
{list.map(([id2, label]) => (
<option key={id2} value={id2}>
{label}
</option>
))}
</select>
) : widget === "color" ? (
<div className="flex gap-2">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
/>
<input
{...common}
readOnly={locked}
placeholder="#138ba0"
className={locked ? inputLocked : common.className}
/>
</div>
) : field.prefix && !locked ? (
// The span is not a form control, so it can't be typed
// into, tabbed to, or selected by dragging through the
// field. Clicking it focuses the input, which is what
// makes the two read as one box.
<div
className={`${inputShell} ${error ? shellError : ""}`}
onClick={() => document.getElementById(id)?.focus()}
>
<span
aria-hidden="true"
className={`shrink-0 select-none ${
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
}`}
>
{field.prefix}
</span>
<input
id={id}
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
/>
</div>
) : (
<input
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
step={widget === "number" ? "any" : undefined}
{...common}
readOnly={locked}
aria-readonly={locked || undefined}
className={locked ? inputLocked : common.className}
/>
)}
</div>
{error ? (
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
) : orphaned && !locked ? (
<p className="mt-1 text-xs text-[#b3261e]">
This points at something that has been deleted. Pick a replacement before saving.
</p>
) : (
field.help &&
widget !== "checkbox" && (
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
)
)}
</div>
);
}
export function FieldGrid({ children }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
const list = rows ?? [];
// Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const rowRefs = useRef([]);
const update = (index, next) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= list.length) return;
const next = [...list];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
const relocate = (from, to) => {
if (from === to || from == null || to == null) return;
const next = [...list];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onChange(next);
};
const endDrag = () => {
setDragIndex(null);
setOverIndex(null);
};
return (
<section className="mt-8">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
<button
type="button"
onClick={() => onChange([...list, { ...spec.blank }])}
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
{spec.addLabel ?? "Add"}
</button>
</div>
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
<div className="mt-3 space-y-3">
{list.map((row, index) => {
const dragging = dragIndex === index;
const over = overIndex === index && dragIndex !== null && !dragging;
return (
<div
key={index}
ref={(el) => {
rowRefs.current[index] = el;
}}
onDragOver={(e) => {
// A null dragIndex means the drag started in some
// other collection — leave it to whoever owns it.
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
if (overIndex !== index) setOverIndex(index);
}}
onDrop={(e) => {
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
relocate(dragIndex, index);
endDrag();
}}
className={
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
(over
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
: "border-[#4a6b72]/20 ") +
(dragging ? "opacity-50" : "")
}
>
<div className="mb-3 flex items-center gap-2">
<span
draggable
onDragStart={(e) => {
e.stopPropagation();
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
// Firefox won't start a drag without payload.
e.dataTransfer.setData("text/plain", String(index));
// Drag the whole row, not just the handle.
const el = rowRefs.current[index];
if (el) e.dataTransfer.setDragImage(el, 16, 16);
}}
onDragEnd={endDrag}
title="Drag to reorder"
aria-hidden="true"
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
>
⠿
</span>
<span className="text-sm font-medium text-[#26454c]">
{spec.title ? spec.title(row, options) : `#${index + 1}`}
</span>
<div className="ml-auto flex gap-1">
<IconButton
label="Move up"
onClick={() => move(index, -1)}
disabled={index === 0}
>
↑
</IconButton>
<IconButton
label="Move down"
onClick={() => move(index, 1)}
disabled={index === list.length - 1}
>
↓
</IconButton>
<IconButton
label="Remove"
danger
onClick={() => onChange(list.filter((_, i) => i !== index))}
>
✕
</IconButton>
</div>
</div>
<FieldGrid>
{spec.fields.map((field) => (
<Field
key={field.path}
field={field}
row={row}
value={row[field.path]}
options={options}
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
onChange={(value) => update(index, { ...row, [field.path]: value })}
/>
))}
</FieldGrid>
{(spec.children ?? []).map((nested) => (
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater
spec={nested}
rows={row[nested.key]}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
onChange={(value) => update(index, { ...row, [nested.key]: value })}
/>
</div>
))}
</div>
);
})}
</div>
</section>
);
}
function IconButton({ label, onClick, danger, disabled, children }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
(disabled
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
: danger
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
}
>
{children}
</button>
);
}

51
src/data/feedbackTypes.js Normal file
View file

@ -0,0 +1,51 @@
/* ═══════════════════════════════════════════════════════════════
FEEDBACK TYPES
Used by the public form (to render the tiles) and the admin
triage view (to label them). The ids must match TYPES in
server/src/routes/feedback.js — that list stays separate because
the server isn't in this workspace, and it's the thing that
decides what's storable.
'general' isn't offered anywhere; it's the server's fallback for
a type it doesn't recognise, so old rows still read sensibly.
═══════════════════════════════════════════════════════════════ */
export const FEEDBACK_TYPES = [
{
id: "broken",
label: "Something's broken",
hint: "A link, image, or button that doesn't work",
},
{
id: "confusing",
label: "Hard to use",
hint: "Something you couldn't find or follow",
},
{
id: "outdated",
label: "Wrong or missing info",
hint: "Old dates, typos, an event that isn't listed",
},
{
id: "request",
label: "Feature request",
hint: "Something you'd like the site to do",
},
{
id: "praise",
label: "Kind words",
hint: "Tell us what's working well",
},
{
id: "other",
label: "Something else",
hint: "Anything that doesn't fit the boxes above",
},
];
const BY_ID = new Map(FEEDBACK_TYPES.map((t) => [t.id, t.label]));
export function feedbackTypeLabel(id) {
return BY_ID.get(id) ?? (id === "general" ? "Unsorted" : id);
}

614
src/lib/adminSchema.js Normal file
View file

@ -0,0 +1,614 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN FORM MANIFESTS
The presentation half of server/src/admin-schema.js: labels,
widgets, grouping, and which select pulls from which option
list. The server decides what's storable; this decides what the
form looks like. Paths here must match column names there, and
nested paths ('region.scope') match the side-table keys.
Adding a field is one entry. Adding an entity is one object here
plus one descriptor on the server — no new page component.
A value seeded in a repeater's `blank` must also be declared as
a default: on the matching server column, or a row the user adds
and never fills in reads as real input instead of being skipped.
═══════════════════════════════════════════════════════════════ */
const PLACE_FIELDS = [
{ path: "venue", label: "Venue" },
{ path: "address", label: "Address", full: true },
{ path: "locality", label: "City" },
{ path: "state_code", label: "State", help: "Two letters, US only" },
{ path: "country", label: "Country", help: "Defaults to US" },
{ path: "location_label", label: "Display location", help: "Overrides the line built from city and state" },
{ path: "latitude", label: "Latitude", widget: "number" },
{ path: "longitude", label: "Longitude", widget: "number" },
{ path: "is_online", label: "Online", widget: "checkbox" },
];
const PUBLISH_FIELDS = [
{ path: "is_published", label: "Published", widget: "checkbox" },
{ path: "sort_order", label: "Sort order", widget: "number" },
];
/* The parts of an affiliation that read the same from either end —
the person's list of roles, and the team's list of members. */
const AFFILIATION_ROLE_FIELDS = [
{ path: "title", label: "Title", help: "Board Chair, Chapter Lead" },
{
path: "role",
label: "Role",
widget: "select",
options: ["lead", "board", "staff", "volunteer", "member"],
},
{ path: "is_owner", label: "Owner", widget: "checkbox", help: "Listed first" },
{ path: "started_on", label: "Started", widget: "date" },
{ path: "ended_on", label: "Ended", widget: "date", help: "Blank means current" },
{ path: "is_public", label: "Public", widget: "checkbox" },
];
/* The two collections every entity carries. */
const linksChild = {
key: "links",
label: "Links and socials",
addLabel: "Add link",
title: (row) => row.label || row.url || "New link",
blank: { kind: "action", label: "", url: "", is_primary: 0 },
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["action", "social", "website", "email"],
},
{ path: "platform", label: "Platform", help: "instagram, discord…" },
{ path: "label", label: "Label", required: true },
{ path: "url", label: "URL", required: true, full: true },
{ path: "is_primary", label: "Primary", widget: "checkbox" },
],
};
const blocksChild = {
key: "content_blocks",
label: "Content blocks",
addLabel: "Add block",
title: (row) => `${row.type ?? "block"} — ${(row.text ?? "").slice(0, 40) || "empty"}`,
blank: { slot: "body", type: "paragraph", text: "" },
fields: [
{ path: "slot", label: "Slot", widget: "select", options: ["card", "body"] },
{
path: "type",
label: "Type",
widget: "select",
options: [
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
},
{ path: "text", label: "Text", widget: "textarea", full: true },
{ path: "media", label: "Media", help: "Filename or URL" },
{ path: "href", label: "Link" },
],
children: [
{
key: "items",
label: "Items",
addLabel: "Add item",
title: (row) => row.text || "New item",
blank: { text: "" },
fields: [
{ path: "text", label: "Text", required: true },
{ path: "detail", label: "Detail" },
{ path: "url", label: "URL", help: "Blank for a plain list item" },
],
},
],
};
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
key: "organizations",
label: "Organizations",
singular: "organization",
idLabel: "Slug",
slugFrom: "name",
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "kind", label: "Kind" },
{ key: "locality", label: "City" },
{ key: "state_code", label: "State" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "kind", label: "Kind", options: ["national", "region", "chapter", "partner"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["national", "region", "chapter", "partner"],
required: true,
help: "Changing this swaps which extra fields apply",
},
{ path: "name", label: "Name", required: true },
{ path: "short_name", label: "Short name" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "color", label: "Colour", widget: "color" },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
],
},
{
legend: "Region",
when: { path: "kind", value: "region" },
fields: [
{
path: "region.scope",
label: "Scope",
widget: "select",
options: ["domestic", "international", "virtual"],
required: true,
},
{ path: "region.map_note", label: "Map note", full: true },
],
},
{
legend: "Chapter",
when: { path: "kind", value: "chapter" },
fields: [
{
path: "chapter.region_id",
label: "Region",
widget: "select",
optionsFrom: "regions",
blankLabel: "— none —",
},
{ path: "chapter.meets", label: "Meets", help: "2nd Sundays, 6:00pm" },
{ path: "chapter.started", label: "Started", help: "Since 2021" },
],
},
{ legend: "Place", fields: PLACE_FIELDS },
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "region_areas",
label: "Map areas",
when: { path: "kind", value: "region" },
addLabel: "Add area",
title: (row) => row.area_code || "New area",
blank: { area_code: "", share: 1 },
fields: [
{ path: "area_code", label: "Area code", required: true, help: "WA, CA, CANADA" },
{ path: "share", label: "Share", widget: "number", help: "1 for the whole tile, 0.5 for half" },
{
path: "edge",
label: "Edge",
widget: "select",
options: ["top", "bottom"],
blankLabel: "— whole tile —",
},
{ path: "note", label: "Note", help: "north, Salt Lake City area" },
],
},
linksChild,
blocksChild,
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
label: "Events",
singular: "event",
idLabel: "Slug",
slugFrom: "title",
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Section" },
{ key: "date_label", label: "Dates" },
{ key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{
path: "section_id",
label: "Section",
widget: "select",
optionsFrom: "event_sections",
required: true,
},
{
path: "host_org_id",
label: "Host",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
help: "Supplies the logo and colour when this event sets neither",
},
{ path: "title", label: "Title", required: true },
{ path: "theme", label: "Theme" },
{ path: "tagline", label: "Tagline", full: true },
],
},
{
legend: "When",
fields: [
{ path: "starts_on", label: "Starts", widget: "date" },
{ path: "ends_on", label: "Ends", widget: "date" },
{
path: "date_label",
label: "Date label",
help: "What the card shows — 'March/April 2026' is fine here",
},
{
path: "status",
label: "Status",
widget: "select",
options: ["upcoming", "past", "cancelled"],
blankLabel: "— derive from end date —",
},
],
},
{ legend: "Where", fields: PLACE_FIELDS },
{
legend: "Appearance",
fields: [
{ path: "event_logo", label: "Event logo", help: "Filename in public/event-logos/" },
{ path: "org_logo", label: "Org logo override" },
{ path: "color", label: "Colour", widget: "color" },
{ path: "gradient", label: "Gradient", full: true },
],
},
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "event_people",
label: "People at this event",
addLabel: "Add person",
title: (row, options) =>
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
blank: { person_id: "", role: "speaker", is_public: 1 },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
{
path: "role",
label: "Role",
widget: "select",
options: [
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
},
{ path: "title", label: "Title", help: "Keynote Speaker" },
{ path: "is_public", label: "Show on the site", widget: "checkbox" },
],
},
linksChild,
blocksChild,
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
label: "People",
singular: "person",
idLabel: "Slug",
slugFrom: "display_name",
list: {
columns: [
{ key: "display_name", label: "Name", primary: true },
{ key: "tagline", label: "Tagline" },
{ key: "locality", label: "City" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{ path: "display_name", label: "Display name", required: true },
{ path: "sort_name", label: "Sort name", help: "Doe, Jane" },
{ path: "pronouns", label: "Pronouns" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "photo", label: "Photo", help: "Filename in public/people/" },
{
path: "primary_org_id",
label: "Home organization",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
help: "Optional. Shown above their bio",
},
{
path: "bio",
label: "Bio",
widget: "textarea",
full: true,
help: "Leave a blank line between paragraphs",
},
],
},
{
legend: "Public contact",
fields: [
{ path: "public_email", label: "Email", help: "Printed on the site" },
{ path: "public_phone", label: "Phone" },
{ path: "locality", label: "City" },
{ path: "state_code", label: "State" },
{ path: "country", label: "Country" },
{ path: "location_label", label: "Display location" },
],
},
{
legend: "Private",
note: "Never sent to the public site. Only admins see this.",
fields: [
{ path: "private.birth_date", label: "Birth date", widget: "date" },
{ path: "private.private_email", label: "Email" },
{ path: "private.private_phone", label: "Phone" },
{ path: "private.address", label: "Address", full: true },
{ path: "private.notes", label: "Notes", widget: "textarea", full: true },
],
},
{
legend: "Publishing",
note: "Unpublished people are invisible everywhere, including as chapter leads.",
fields: PUBLISH_FIELDS,
},
],
children: [
{
key: "affiliations",
label: "Roles and organizations",
addLabel: "Add role",
note:
"Where someone appears within a team is set on that team's page, " +
"not by the order of this list.",
title: (row, options) =>
[row.title, options?.organizations?.find((o) => o.id === row.org_id)?.label]
.filter(Boolean)
.join(" · ") || "New role",
blank: { org_id: "", role: "member", is_public: 1, is_owner: 0 },
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
required: true,
},
{
path: "team_id",
label: "Team",
widget: "select",
optionsFrom: "teams",
blankLabel: "— none —",
// A team belongs to an org; offering one from another
// org would fail the composite foreign key on save.
filterBy: (option, row) => option.org_id === row.org_id,
help: "Only teams of the chosen organization",
},
...AFFILIATION_ROLE_FIELDS,
],
},
{
key: "person_awards",
label: "Awards",
addLabel: "Add award",
title: (row, options) =>
options?.awards?.find((a) => a.id === row.award_id)?.label ?? "New award",
blank: { award_id: "", is_public: 1 },
fields: [
{
path: "award_id",
label: "Award",
widget: "select",
optionsFrom: "awards",
required: true,
help: "Labelled with the organization that gives it",
},
{
path: "event_id",
label: "Presented at",
widget: "select",
optionsFrom: "events",
blankLabel: "— none —",
},
{ path: "awarded_on", label: "Date", widget: "date" },
{ path: "citation", label: "Citation", widget: "textarea", full: true },
{ path: "is_public", label: "Public", widget: "checkbox" },
],
},
linksChild,
blocksChild,
],
};
/* ── Teams ───────────────────────────────────────────────────── */
const teams = {
key: "teams",
label: "Teams",
singular: "team",
idLabel: "Slug",
// teams.id is a global primary key, not scoped to the org, so
// 'board' can only exist once across the whole site. Composing
// the slug from both fields is what keeps NGU's board and the
// Northwest's board from colliding.
slugFrom: ["org_id", "name"],
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Organization" },
{ key: "tagline", label: "Tagline" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "org_id", label: "Organization", optionsFrom: "organizations" },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
note:
"Pick the organization first: the slug is built from it, " +
"and it can't be changed once anyone is filed under this team.",
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
required: true,
help: "Who this team belongs to",
},
{ path: "name", label: "Name", required: true, help: "Board, Leadership Team" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "color", label: "Colour", widget: "color" },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
],
},
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "members",
label: "Members",
addLabel: "Add member",
note:
"This order is the order they appear on the site. Owners are " +
"still listed first, in this order among themselves.",
title: (row, options) =>
[options?.people?.find((p) => p.id === row.person_id)?.label, row.title]
.filter(Boolean)
.join(" · ") || "New member",
blank: { person_id: "", role: "member", is_public: 1, is_owner: 0 },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
...AFFILIATION_ROLE_FIELDS,
],
},
linksChild,
blocksChild,
],
};
/* ── Awards ──────────────────────────────────────────────────── */
const awards = {
key: "awards",
label: "Awards",
singular: "award",
idLabel: "Slug",
slugFrom: "name",
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Awarded by" },
{ key: "description", label: "Description" },
{ key: "sort_order", label: "Order" },
],
filters: [
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
],
},
groups: [
{
legend: "Identity",
note:
"An award exists whether or not anyone has received it. " +
"Who received it is edited on the person.",
fields: [
{
path: "org_id",
label: "Awarded by",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— unattributed —",
help: "The organization that gives this award",
},
{ path: "name", label: "Name", required: true },
{ path: "description", label: "Description", widget: "textarea", full: true },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
{ path: "sort_order", label: "Sort order", widget: "number" },
],
},
],
};
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
export function slugify(value) {
return String(value ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 64);
}

30
src/lib/adminTitle.tsx Normal file
View file

@ -0,0 +1,30 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN TITLE
The layout knows which section you're in; only the page knows
which record is on screen. Rather than have both write
document.title and race — child effects run before parent
effects, so the layout would win and the record name would
never survive — the page publishes a string and the layout is
the single writer.
Null is the normal state. A page that publishes nothing, or one
still loading, simply leaves the section title showing.
═══════════════════════════════════════════════════════════════ */
import { createContext, useContext, useEffect } from "react";
export const AdminTitleContext = createContext(null);
/* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in
the tab. */
export function useAdminDetail(name) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {
if (!setDetail) return undefined;
setDetail(name || null);
return () => setDetail(null);
}, [setDetail, name]);
}

View file

@ -100,3 +100,15 @@ export function post(path, data) {
body: JSON.stringify(data),
});
}
export function patch(path, data) {
return request(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
export function del(path) {
return request(path, { method: "DELETE" });
}

98
src/lib/auth.tsx Normal file
View file

@ -0,0 +1,98 @@
/* ═══════════════════════════════════════════════════════════════
AUTH CONTEXT
One fetch of /api/auth/me at mount decides whether anyone is
signed in. There's no token to store: the session cookie is
HttpOnly, so this code can't read it and neither can anything
injected into the page. "Am I signed in" is always a question
for the server.
RequireAuth guards routes. Worth being clear about what that
does and doesn't do: it hides the interface, not the data. The
protection is the 401 from /api/admin — this only stops someone
staring at an empty table.
═══════════════════════════════════════════════════════════════ */
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
get("/auth/me", { ttl: 0 })
.then((data) => {
if (!ignore) setUser(data.user);
})
.catch(() => {
if (!ignore) setUser(null); // 401 is the normal case
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, []);
const login = useCallback(async (email, password) => {
const data = await post("/auth/login", { email, password });
setUser(data.user);
return data.user;
}, []);
const logout = useCallback(async () => {
try {
await post("/auth/logout", {});
} finally {
// Whatever the server said, this browser is done.
setUser(null);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth used outside AuthProvider");
return value;
}
/* Signals a session that ended while the page was open — the
admin pages call this when a request comes back 401. */
export function isUnauthorized(error) {
return error instanceof ApiError && error.status === 401;
}
export function RequireAuth() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-[#4a6b72]">
Checking your session…
</div>
);
}
if (!user) {
// `from` lets the login page send them back where they aimed.
return <Navigate to="/admin/login" state={{ from: location }} replace />;
}
return <Outlet />;
}

View file

@ -1,63 +1,6 @@
import PageShell from "../components/PageShell.tsx";
import PeopleTiles from "../components/PeopleTiles.tsx";
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
// the component only cares about the shape, not where it came from.
export const leadershipGroups = [
{
id: "director",
label: "Retreat Director",
accent: "#138ba0",
people: [
{
id: 1,
name: "John Doe",
title: "Retreat Director",
photo: "/people/jordan-ellis.jpg",
pronouns: "he/him",
birthdate: "1994-03-08",
org: { name: "Unity of Des Moines", href: "https://example.org" },
bio: [
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
],
},
],
},
{
id: "team",
label: "Retreat Team",
people: [
{
id: 2,
name: "Priya Raman",
title: "Events Lead",
photo: "/people/priya-raman.jpg",
pronouns: "she/her",
age: 27,
org: "Unity Chicago",
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
},
{
id: 3,
name: "Sam Okafor",
title: "Communications",
pronouns: "they/them",
age: 24,
org: "Unity of Milwaukee",
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
},
],
},
];
export const board = [
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
];
const SECTIONS = [
{
id: "board-staff",
@ -67,7 +10,7 @@ const SECTIONS = [
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="sm" people={board} overflow="scroll" />
<PeopleTiles size="lg" teams={{ id: "ngu-board",}} />
</div>
),
},
@ -79,7 +22,7 @@ const SECTIONS = [
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="lg" groups={leadershipGroups} />
<PeopleTiles size="lg" teams="ngu-retreat-team" />
</div>
),
},

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

View file

@ -14,6 +14,7 @@
import { useState } from "react";
import { post, ApiError } from "../../lib/api.js";
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
const ACCENT = "#138ba0";
const MUTED = "#4a6b72";
@ -25,39 +26,6 @@ const SITE_WIDE = "site";
// Sentinel for "this page, but not one section of it".
const WHOLE_PAGE = "";
// Ids must match TYPES in server/src/routes/feedback.js.
const FEEDBACK_TYPES = [
{
id: "broken",
label: "Something's broken",
hint: "A link, image, or button that doesn't work",
},
{
id: "confusing",
label: "Hard to use",
hint: "Something you couldn't find or follow",
},
{
id: "outdated",
label: "Wrong or missing info",
hint: "Old dates, typos, an event that isn't listed",
},
{
id: "request",
label: "Feature request",
hint: "Something you'd like the site to do",
},
{
id: "praise",
label: "Kind words",
hint: "Tell us what's working well",
},
{
id: "other",
label: "Something else",
hint: "Anything that doesn't fit the boxes above",
},
];
/* ── Shared field chrome ─────────────────────────────────────── */