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

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

View file

@ -9,22 +9,45 @@
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js role them@ngu.org editor
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
add takes --role=viewer for read-only, --name="Full Name". Roles, low to high. Each one can do everything the one above it
Press enter at the password prompt and it generates one and in this list can:
prints it once.
Changing or disabling a password also drops that person's live viewer read the CMS, change nothing
sessions, so "disable" takes effect now rather than in 30 days. editor + create and update records
admin + delete records
superadmin + accounts, roles and sessions, via /admin/panel
add takes --role=editor, --name="Full Name". It defaults to
admin. Press enter at the password prompt and it generates one
and prints it once.
The list comes from auth.js rather than being repeated here, so
the CLI can't drift from what requireRole will actually accept.
This is also how the first superadmin is made — there's no
bootstrap path in the web interface, on purpose:
node src/admin-cli.js role you@ngu.org superadmin
Changing a password, a role, or disabling an account drops that
person's live sessions, so it takes effect now rather than in
30 days.
Nothing here refuses to demote or disable the last superadmin.
The panel does, because a misclick there locks everyone out;
here you're already root on the box holding the database, and a
recovery tool that argues with you isn't one.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { openDatabase, migrate } from "./db.js"; import { openDatabase, migrate } from "./db.js";
import { hashPassword, destroyAllSessionsFor } from "./auth.js"; import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js";
const MIN_PASSWORD = 12; const MIN_PASSWORD = 12;
@ -86,11 +109,37 @@ function findUser(db, email) {
.get(email); .get(email);
} }
function checkRole(role) {
if (!ROLES.includes(role)) {
fail(`Role must be one of: ${ROLES.join(", ")}.`);
}
return role;
}
/* Printed, never enforced — see the note at the top of the file.
Worth saying out loud, because the person doing it is usually
tidying up accounts rather than thinking about lockouts. */
function warnIfLastSuper(db, user) {
if (user.role !== "superadmin" || user.is_active !== 1) return;
const { n } = db
.prepare(
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
)
.get();
if (n <= 1) {
console.warn(
"⚠ That's the last active superadmin. Nobody will be able to manage\n" +
" accounts from /admin/panel until you promote someone here.",
);
}
}
async function add(db, email, flags) { async function add(db, email, flags) {
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`); if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
const role = flags.role ?? "admin"; const role = checkRole(flags.role ?? "admin");
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
const password = await readPassword(); const password = await readPassword();
@ -117,10 +166,36 @@ async function passwd(db, email) {
console.log(`✓ password changed for ${email}, existing sessions ended`); console.log(`✓ password changed for ${email}, existing sessions ended`);
} }
function setRole(db, email, role) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
checkRole(role);
if (user.role === role) {
console.log(`· ${email} is already ${role}, nothing to do`);
return;
}
// Only a demotion can strand the account list.
if (role !== "superadmin") warnIfLastSuper(db, user);
db.prepare("UPDATE admin_users SET role = ? WHERE id = ?").run(role, user.id);
// The session they're holding was issued against the old role.
// Every check reads the row fresh, so it isn't a security hole —
// but their open tab would keep drawing buttons that now 403.
destroyAllSessionsFor(db, user.id);
console.log(`✓ ${email} is now ${role} (was ${user.role}), sessions ended`);
}
function setActive(db, email, active) { function setActive(db, email, active) {
const user = findUser(db, email); const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`); if (!user) fail(`No account for ${email}.`);
if (!active) warnIfLastSuper(db, user);
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run( db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
active ? 1 : 0, active ? 1 : 0,
user.id, user.id,
@ -147,10 +222,13 @@ function list(db) {
} }
for (const r of rows) { for (const r of rows) {
const state = r.is_active ? r.role : "disabled"; // Role and state are separate facts now. The old single column
// printed "disabled" over the top of the role, which hid what
// the account would go back to on enable.
const state = r.is_active ? r.role : `${r.role} (disabled)`;
const seen = r.last_login_at ?? "never"; const seen = r.last_login_at ?? "never";
console.log( console.log(
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, `${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
); );
} }
} }
@ -175,24 +253,38 @@ migrate(db); // so a fresh database gets the tables before we use them
try { try {
switch (command) { switch (command) {
case "add": case "add":
if (!email) fail("Usage: admin-cli.js add email@example.com"); if (!email) fail("Usage: admin-cli.js add email@example.com [--role=editor]");
await add(db, email, flags); await add(db, email, flags);
break; break;
case "passwd": case "passwd":
if (!email) fail("Usage: admin-cli.js passwd email@example.com"); if (!email) fail("Usage: admin-cli.js passwd email@example.com");
await passwd(db, email); await passwd(db, email);
break; break;
case "role": {
// Positional reads better for a two-argument command, but
// --role= is what `add` takes, so accept both rather than
// making people remember which is which.
const role = positional[1]?.trim().toLowerCase() ?? flags.role;
if (!email || !role) {
fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`);
}
setRole(db, email, role);
break;
}
case "disable": case "disable":
if (!email) fail("Usage: admin-cli.js disable email@example.com");
setActive(db, email, false); setActive(db, email, false);
break; break;
case "enable": case "enable":
if (!email) fail("Usage: admin-cli.js enable email@example.com");
setActive(db, email, true); setActive(db, email, true);
break; break;
case "list": case "list":
list(db); list(db);
break; break;
default: default:
console.log("Commands: add, passwd, disable, enable, list"); console.log("Commands: add, passwd, role, disable, enable, list");
console.log(`Roles: ${ROLES.join(", ")}`);
process.exit(command ? 1 : 0); process.exit(command ? 1 : 0);
} }
} finally { } finally {

View file

@ -153,7 +153,8 @@ export function listRows(db, entity, query = {}) {
return { rows, total: rows.length }; return { rows, total: rows.length };
} }
export function readRow(db, entity, id) { export function readRow(db, entity, rawId) {
const id = normalizeId(entity, rawId);
const row = db const row = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id); .get(id);
@ -161,10 +162,7 @@ export function readRow(db, entity, id) {
if (!row) throw new HttpError(404, "Not found."); if (!row) throw new HttpError(404, "Not found.");
for (const ext of entity.extensions ?? []) { for (const ext of entity.extensions ?? []) {
row[ext.key] = row[ext.key] = readExtension(db, ext, id);
db
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
.get(id) ?? null;
} }
for (const child of entity.children ?? []) { for (const child of entity.children ?? []) {
@ -174,6 +172,29 @@ export function readRow(db, entity, id) {
return row; return row;
} }
/* A 1:1 side table is found one of two ways. `idColumn` is the
original: the side table's key IS the parent's id, which is how
regions and person_private work. `owner` is the same block children
already use — a foreign key column plus an optional kind discriminator
— and it exists because a timeline entry is keyed by (ref_kind,
ref_id) rather than by the event's own slug. Same upsert either way;
only the WHERE differs. */
function extensionWhere(ext, id) {
if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] };
const where = [`${ext.owner.column} = ?`];
const params = [id];
if (ext.owner.kindColumn) {
where.push(`${ext.owner.kindColumn} = ?`);
params.push(ext.owner.kindValue);
}
return { sql: where.join(" AND "), params };
}
function readExtension(db, ext, id) {
const { sql, params } = extensionWhere(ext, id);
return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null;
}
function readChildren(db, child, ownerId) { function readChildren(db, child, ownerId) {
const where = [`${child.owner.column} = ?`]; const where = [`${child.owner.column} = ?`];
const params = [ownerId]; const params = [ownerId];
@ -208,9 +229,29 @@ function readChildren(db, child, ownerId) {
/* ── Write ───────────────────────────────────────────────────── */ /* ── Write ───────────────────────────────────────────────────── */
export function createRow(db, entity, payload) { /* An entity whose id is an autoincrement integer is addressed by a
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase(); number, and a number arriving from a URL segment is a string. Every
comparison against the id column goes through here so the two can't
drift apart. */
export function normalizeId(entity, id) {
if (entity.idKind !== "auto") return id;
const n = Number(id);
if (!Number.isInteger(n)) throw new HttpError(404, "Not found.");
return n;
}
export function createRow(db, entity, payload) {
// idKind "auto": the table assigns the id, so there is nothing to
// validate, nothing to check for collisions, and nothing for the
// client to have sent. Timeline entries use this — they have no
// natural name to slug, and one gets created every time somebody
// ticks a checkbox on an event.
const auto = entity.idKind === "auto";
const id = auto
? null
: String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
if (!auto) {
if (entity.idKind === "slug" && !SLUG.test(id)) { if (entity.idKind === "slug" && !SLUG.test(id)) {
throw new HttpError(422, "Validation failed", { throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.", [entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
@ -225,30 +266,50 @@ export function createRow(db, entity, payload) {
[entity.idColumn]: "Already taken.", [entity.idColumn]: "Already taken.",
}); });
} }
}
const { values, errors } = coerceRow(entity.columns, payload); const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) { if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors); throw new HttpError(422, "Validation failed", errors);
} }
const names = [entity.idColumn, ...Object.keys(values)]; let newId = id;
wrapDbErrors(() => wrapDbErrors(() =>
tx(db, () => { tx(db, () => {
if (auto) {
const names = Object.keys(values);
// Every column omitted is legitimate here: a blank entry that
// takes all its defaults. INSERT INTO t () VALUES () is not
// valid SQL, so that case needs DEFAULT VALUES.
const result = names.length
? db
.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
)
.run(...Object.values(values))
: db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run();
// better-sqlite3 and node:sqlite disagree about BigInt here.
newId = Number(result.lastInsertRowid);
} else {
const names = [entity.idColumn, ...Object.keys(values)];
db.prepare( db.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")}) `INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`, VALUES (${names.map(() => "?").join(", ")})`,
).run(id, ...Object.values(values)); ).run(id, ...Object.values(values));
}
writeExtensions(db, entity, id, payload, values); writeExtensions(db, entity, newId, payload, values);
writeChildren(db, entity, id, payload, values); writeChildren(db, entity, newId, payload, values);
}), }),
); );
return readRow(db, entity, id); return readRow(db, entity, newId);
} }
export function updateRow(db, entity, id, payload) { export function updateRow(db, entity, rawId, payload) {
const id = normalizeId(entity, rawId);
const current = db const current = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id); .get(id);
@ -296,7 +357,8 @@ export function updateRow(db, entity, id, payload) {
return readRow(db, entity, id); return readRow(db, entity, id);
} }
export function deleteRow(db, entity, id) { export function deleteRow(db, entity, rawId) {
const id = normalizeId(entity, rawId);
const result = wrapDbErrors(() => const result = wrapDbErrors(() =>
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id), db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
); );
@ -309,8 +371,10 @@ function writeExtensions(db, entity, id, payload, parentValues) {
for (const ext of entity.extensions ?? []) { for (const ext of entity.extensions ?? []) {
if (!applies(ext.when, parentValues)) { if (!applies(ext.when, parentValues)) {
// The gate closed — the kind changed away from this side // The gate closed — the kind changed away from this side
// table, so its row (and anything cascading off it) goes. // table, or a checkbox was unticked — so its row (and anything
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id); // cascading off it) goes.
const gone = extensionWhere(ext, id);
db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
continue; continue;
} }
@ -323,21 +387,42 @@ function writeExtensions(db, entity, id, payload, parentValues) {
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19); if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
const names = [ext.idColumn, ...Object.keys(values)]; // The owning columns come first, then whatever the form sent.
const ownNames = [];
const ownParams = [];
if (ext.owner) {
ownNames.push(ext.owner.column);
ownParams.push(id);
if (ext.owner.kindColumn) {
ownNames.push(ext.owner.kindColumn);
ownParams.push(ext.owner.kindValue);
}
} else {
ownNames.push(ext.idColumn);
ownParams.push(id);
}
const names = [...ownNames, ...Object.keys(values)];
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`); const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
// What makes this row the same row on a second save. Defaults to
// the id column; an owned extension declares the unique index its
// owning columns form.
const conflict = ext.conflict ?? ownNames;
// Upsert rather than delete-and-insert: deleting a regions row // Upsert rather than delete-and-insert: deleting a regions row
// would cascade its region_areas away underneath us. With every // would cascade its region_areas away underneath us, and deleting
// a timeline row would take its people with it. With every
// optional column omitted there is nothing to set, so the // optional column omitted there is nothing to set, so the
// conflict clause has to degrade to DO NOTHING or the SQL is // conflict clause has to degrade to DO NOTHING or the SQL is
// syntactically invalid. // syntactically invalid.
db.prepare( db.prepare(
`INSERT INTO ${ext.table} (${names.join(", ")}) `INSERT INTO ${ext.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")}) VALUES (${names.map(() => "?").join(", ")})
ON CONFLICT(${ext.idColumn}) ${ ON CONFLICT(${conflict.join(", ")}) ${
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING" sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
}`, }`,
).run(id, ...Object.values(values)); ).run(...ownParams, ...Object.values(values));
} }
} }
@ -528,6 +613,19 @@ function wrapDbErrors(fn) {
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`); throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
} }
// The polymorphic tables stand in for a foreign key with a
// BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none
// of the patterns above — so without this, pointing a content
// block, link or timeline entry at a row that isn't there is a 500
// rather than something the form can show.
const ghost = /^(\w+): no such (\w+)$/.exec(message);
if (ghost) {
throw new HttpError(
422,
`That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`,
);
}
throw err; throw err;
} }
} }

View file

@ -53,7 +53,15 @@ function collectGroups(entity) {
groups.push({ groups.push({
table: ext.table, table: ext.table,
columns: ext.columns ?? [], columns: ext.columns ?? [],
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])], // An extension is keyed either by the parent's own id or by an
// owner block, the same one children use. Both sets of columns
// are filled in by the engine, never by the form.
engineSupplied: [
ext.idColumn,
ext.owner?.column,
ext.owner?.kindColumn,
...(ext.touch ? ["updated_at"] : []),
].filter(Boolean),
}); });
} }

View file

@ -74,6 +74,51 @@ const affiliationRole = [
bool("is_public"), bool("is_public"),
]; ];
/* The editable half of a timeline entry, shared by the standalone
editor and by the in_timeline extension on events and organizations.
occurred_on is text, not date. "2012" and "2025-07" are legitimate
values — a backfilled entry often knows the year and nothing more —
and the date coercion would reject both. `precision` is what says how
much of it to believe. */
const timelineFields = [
text("occurred_on"),
enumeration("precision", ["year", "month", "day"]),
text("title"),
text("blurb"),
text("meta"),
text("link_url"),
bool("is_featured"),
bool("is_published"),
int("sort_order"),
];
/* The extension that the in_timeline checkbox drives. Ticked, the row
is upserted; unticked, writeExtensions deletes it. Both happen in the
parent's transaction, so the flag and the row cannot disagree.
The conflict target is the UNIQUE (ref_kind, ref_id) index from
migration 007, which is also what stops a second save creating a
duplicate instead of updating the first. */
const timelineExtension = (refKind) => ({
key: "timeline",
table: "timeline_entries",
owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind },
conflict: ["ref_kind", "ref_id"],
when: { column: "in_timeline", value: 1 },
columns: [
// Fixed for this end: an event's entry is always an event entry.
// Declared as a default rather than a form field so the column is
// written without asking.
enumeration(
"kind",
["milestone", "event", "organization", "award", "people"],
{ default: refKind === "organization" ? "organization" : refKind },
),
...timelineFields,
],
});
/* The two polymorphic collections, parameterised by owner_kind. */ /* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({ const linksChild = (ownerKind) => ({
key: "links", key: "links",
@ -131,6 +176,26 @@ const blocksChild = (ownerKind) => ({
], ],
}); });
/* Hosts. One row is one host, ordered, each either an organization
or a person — the CHECK on event_hosts rejects both and the blank
filter drops neither, so the only bad row that reaches SQLite is
one with both selects filled, and that comes back keyed to the
row like any other field error.
Not parameterised the way links and blocks are: this table is
events-only, and the owner column says so.
sort_order isn't declared. The engine writes it from the row's
position because `order` is "sort_order", which is what makes
the first row the one v_events takes the logo and colour from. */
const hostsChild = {
key: "event_hosts",
table: "event_hosts",
owner: { column: "event_id" },
order: "sort_order",
columns: [text("org_id"), text("person_id")],
};
/* ── Organizations ───────────────────────────────────────────── */ /* ── Organizations ───────────────────────────────────────────── */
const organizations = { const organizations = {
@ -169,9 +234,11 @@ const organizations = {
...placeColumns, ...placeColumns,
bool("is_published"), bool("is_published"),
int("sort_order"), int("sort_order"),
bool("in_timeline"),
], ],
extensions: [ extensions: [
timelineExtension("organization"),
{ {
key: "region", key: "region",
table: "regions", table: "regions",
@ -226,7 +293,7 @@ const events = {
"id", "id",
"title", "title",
"section_id", "section_id",
"host_org_id", "event_type",
"date_label", "date_label",
"starts_on", "starts_on",
"status", "status",
@ -234,14 +301,29 @@ const events = {
"sort_order", "sort_order",
"updated_at", "updated_at",
], ],
filters: ["section_id", "status", "is_published", "host_org_id"], // No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"], search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title", order: "sort_order, starts_on DESC, title",
}, },
columns: [ columns: [
text("section_id", { required: true }), text("section_id", { required: true }),
text("host_org_id"),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
// this only ever asks on create.
enumeration(
"event_type",
["retreat", "class", "workshop", "meeting", "other"],
{ required: true },
),
text("title", { required: true }), text("title", { required: true }),
text("theme"), text("theme"),
text("tagline"), text("tagline"),
@ -256,9 +338,13 @@ const events = {
text("gradient"), text("gradient"),
bool("is_published"), bool("is_published"),
int("sort_order"), int("sort_order"),
bool("in_timeline"),
], ],
extensions: [timelineExtension("event")],
children: [ children: [
hostsChild,
linksChild("event"), linksChild("event"),
blocksChild("event"), blocksChild("event"),
{ {
@ -485,7 +571,69 @@ const awards = {
], ],
}; };
export const ENTITIES = { organizations, events, people, teams, awards }; /* ── Timeline ────────────────────────────────────────────────── */
// The history page's spine, and the only entity whose id the table
// assigns. There is nothing to slug: an entry referencing an event has
// no name of its own, and one gets created every time somebody ticks a
// checkbox. idKind "auto" is what lets createRow skip the id entirely.
//
// ref_kind and ref_id are writable here and only here. The extension on
// events and organizations owns those two columns for rows it created,
// which is why they aren't in timelineFields.
//
// Deleting an entry takes its people with it (ON DELETE CASCADE) and
// nothing points at an entry, so the delete-and-reinsert child engine
// is safe on this one.
const timeline = {
key: "timeline",
table: "timeline_entries",
idColumn: "id",
idKind: "auto",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"ref_kind",
"ref_id",
"occurred_on",
"title",
"is_featured",
"is_published",
"updated_at",
],
filters: ["kind", "ref_kind", "is_featured", "is_published"],
search: ["title", "blurb", "meta", "ref_id"],
// Undated entries sort last rather than first, so a missing date
// reads as something to fix instead of something to scroll past.
order: "occurred_on IS NULL, occurred_on DESC, sort_order",
},
columns: [
enumeration(
"kind",
["milestone", "event", "organization", "award", "people"],
{ required: true },
),
enumeration("ref_kind", ["event", "organization", "award", "person", "team"]),
text("ref_id"),
...timelineFields,
],
children: [
{
key: "people",
table: "timeline_entry_people",
owner: { column: "entry_id" },
order: "sort_order",
columns: [text("person_id", { required: true }), text("note")],
},
],
};
export const ENTITIES = { organizations, events, people, teams, awards, timeline };
/* ── Options for the form's select inputs ────────────────────── */ /* ── Options for the form's select inputs ────────────────────── */
@ -503,6 +651,22 @@ export const OPTION_QUERIES = {
teams: teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name", "SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// One flat list the ref picker filters by ref_kind, rather than five
// dropdowns of which four are always wrong. `kind` is the discriminator
// the client's filterBy matches on; org_kind disambiguates the label,
// since a region and a chapter can share a name.
timeline_refs: `
SELECT 'event' AS kind, id, title AS label FROM events
UNION ALL
SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations
UNION ALL
SELECT 'award', id, name FROM awards
UNION ALL
SELECT 'person', id, display_name FROM people
UNION ALL
SELECT 'team', id, name FROM teams
ORDER BY kind, label`,
// The awarding organization is folded into the label instead, // The awarding organization is folded into the label instead,
// because a person can receive an award from any organization — // because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate // there is nothing to filter on, only something to disambiguate

View file

@ -198,10 +198,14 @@ export async function requireAuth(c, next) {
await next(); await next();
} }
export const ROLES = ["viewer", "editor", "admin", "superadmin"];
const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 };
export function requireRole(...roles) { export function requireRole(...roles) {
const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity));
return async (c, next) => { return async (c, next) => {
const user = c.get("user"); const user = c.get("user");
if (!user || !roles.includes(user.role)) { if (!user || (RANK[user.role] ?? 0) < need) {
return c.json({ error: "Not allowed." }, 403); return c.json({ error: "Not allowed." }, 403);
} }
await next(); await next();

View file

@ -16,9 +16,11 @@ import { openDatabase, migrate } from "./db.js";
import { rateLimit } from "./rateLimit.js"; import { rateLimit } from "./rateLimit.js";
import content from "./routes/content.js"; import content from "./routes/content.js";
import people from "./routes/people.js"; import people from "./routes/people.js";
import history from "./routes/history.js";
import feedback from "./routes/feedback.js"; import feedback from "./routes/feedback.js";
import auth from "./routes/auth.js"; import auth from "./routes/auth.js";
import admin from "./routes/admin.js"; import admin from "./routes/admin.js";
import panel from "./routes/panel.js";
import adminEntities from "./routes/admin-entities.js"; import adminEntities from "./routes/admin-entities.js";
import { startSessionSweeper } from "./auth.js"; import { startSessionSweeper } from "./auth.js";
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js"; import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
@ -53,12 +55,14 @@ app.get("/api/health", (c) =>
app.route("/api", content); app.route("/api", content);
app.route("/api", people); app.route("/api", people);
app.route("/api", history);
// Tighter limit on the write path than anything else gets. // Tighter limit on the write path than anything else gets.
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
app.route("/api/feedback", feedback); app.route("/api/feedback", feedback);
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 })); app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
app.route("/api/admin/panel", panel);
app.route("/api/auth", auth); app.route("/api/auth", auth);
app.route("/api/admin", admin); app.route("/api/admin", admin);
app.route("/api/admin", adminEntities); app.route("/api/admin", adminEntities);

View file

@ -0,0 +1,279 @@
-- ═══════════════════════════════════════════════════════════════
-- 007 TIMELINE
--
-- The history page's spine. One row per thing worth putting on the
-- rail, and — this is the whole point — a row that points at an
-- event holds almost nothing of its own. Title, date and logo are
-- read back from `events` at query time, so editing the event edits
-- the timeline and there is no second copy to drift.
--
-- Decade headers are NOT here. There are four of them, they change
-- about never, and they are editorial voice rather than record; they
-- live in src/data/historyDecades.ts.
--
-- ref_kind + ref_id is polymorphic, matching content_blocks and
-- links rather than inventing a second pattern. SQLite can't express
-- that as a foreign key, so the triggers below do the work one
-- would, exactly as those two tables already do.
--
-- PRAGMA user_version; -- was 6 before this file
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE timeline_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- What the entry is about, which drives the marker and the body
-- layout on the page. Usually mirrors ref_kind; 'people' is the
-- exception, being a team ref rendered as a roster, and
-- 'milestone' is the free-standing case with no ref at all.
kind TEXT NOT NULL DEFAULT 'milestone'
CHECK (kind IN ('milestone', 'event', 'organization',
'award', 'people')),
ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award',
'person', 'team')),
ref_id TEXT,
-- Null inherits from the referenced row: an event's starts_on. A
-- hand-authored entry has to supply its own, which the descriptor
-- can't require conditionally — the read layer reports an entry
-- with neither rather than the table refusing it.
occurred_on TEXT,
-- How much of occurred_on is trustworthy. Backfilled rows often
-- have a full date where only the year is actually known, and
-- 'year' is what routes them to "Elsewhere in 2009" instead of
-- asserting a month nobody can source.
precision TEXT NOT NULL DEFAULT 'day'
CHECK (precision IN ('year', 'month', 'day')),
-- All null-inherits-from-the-ref. Filling one in is an override,
-- for when the timeline wants to say something the event card
-- doesn't.
title TEXT,
blurb TEXT,
meta TEXT,
link_url TEXT,
is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)),
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Half a reference is worse than none: it would resolve to a link
-- with no destination and no way to notice.
CHECK ((ref_kind IS NULL) = (ref_id IS NULL)),
-- One timeline entry per referenced record, which is what makes
-- the in_timeline checkbox an upsert rather than a duplicate
-- factory. SQLite permits any number of NULL pairs here, so
-- hand-authored entries are unaffected.
UNIQUE (ref_kind, ref_id)
) STRICT;
CREATE INDEX timeline_entries_date_idx
ON timeline_entries (is_published, occurred_on DESC);
-- Who an entry is about, when it isn't a whole team. A 'people'
-- entry naming a team resolves its roster through v_org_leadership
-- instead and leaves this table empty; this is for the cases where
-- the list is editorial rather than structural.
--
-- Safe for the CRUD engine's delete-and-reinsert because nothing
-- references these rows.
CREATE TABLE timeline_entry_people (
entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
note TEXT, -- 'Founding lead'
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (entry_id, person_id)
) STRICT;
CREATE INDEX timeline_entry_people_person_idx
ON timeline_entry_people (person_id);
-- ── The checkbox on the event and organization editors ─────────
--
-- Not a denormalised copy of "does a timeline row exist" — it is the
-- gate the admin descriptor reads. Ticked, the extension upserts a
-- timeline_entries row; unticked, the engine deletes it. The flag
-- and the row are written in the same transaction, so they cannot
-- disagree.
ALTER TABLE events
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
CHECK (in_timeline IN (0, 1));
ALTER TABLE organizations
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
CHECK (in_timeline IN (0, 1));
-- ── Integrity for the polymorphic reference ────────────────────
CREATE TRIGGER timeline_entries_ref_exists
BEFORE INSERT ON timeline_entries
BEGIN
SELECT CASE
WHEN new.ref_kind = 'event'
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such event')
WHEN new.ref_kind = 'organization'
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such organization')
WHEN new.ref_kind = 'award'
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such award')
WHEN new.ref_kind = 'person'
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such person')
WHEN new.ref_kind = 'team'
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such team')
END;
END;
-- The same check on update, because the standalone editor can
-- repoint an entry at a different record.
CREATE TRIGGER timeline_entries_ref_exists_update
BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries
BEGIN
SELECT CASE
WHEN new.ref_kind = 'event'
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such event')
WHEN new.ref_kind = 'organization'
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such organization')
WHEN new.ref_kind = 'award'
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such award')
WHEN new.ref_kind = 'person'
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such person')
WHEN new.ref_kind = 'team'
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
THEN RAISE(ABORT, 'timeline_entries: no such team')
END;
END;
-- Deleting the record deletes its entry. Separate triggers rather
-- than editing the existing *_cleanup ones, so this migration adds
-- and never rewrites.
CREATE TRIGGER timeline_events_cleanup
AFTER DELETE ON events
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id;
END;
CREATE TRIGGER timeline_organizations_cleanup
AFTER DELETE ON organizations
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id;
END;
CREATE TRIGGER timeline_awards_cleanup
AFTER DELETE ON awards
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id;
END;
CREATE TRIGGER timeline_people_cleanup
AFTER DELETE ON people
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id;
END;
CREATE TRIGGER timeline_teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
END;
-- updated_at, with the same WHEN guard as the other touch triggers
-- so an explicit value passes through untouched on import.
CREATE TRIGGER timeline_entries_touch
AFTER UPDATE ON timeline_entries
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id;
END;
-- ── Read view ──────────────────────────────────────────────────
--
-- Every fallback the page depends on, resolved once here rather than
-- restated by each route. An entry with no title of its own takes
-- the referenced record's name; with no date, the event's starts_on.
--
-- org_kind rides along because /regions, /chapters and /partners are
-- three different routes and only this table knows which a slug is.
--
-- effective_date is the sort key. An entry that ended up with no
-- date at all sorts last rather than vanishing, so a missing one is
-- visible in the admin instead of silently absent from the page.
CREATE VIEW v_timeline AS
SELECT
t.id,
t.kind,
t.ref_kind,
t.ref_id,
t.precision,
t.is_featured,
t.is_published,
t.sort_order,
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
COALESCE(
t.title,
e.title,
o.name,
aw.name,
p.display_name,
tm.name
) AS effective_title,
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
AS effective_blurb,
t.meta,
t.link_url,
-- Filename only. The directory is the frontend's business.
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
AS effective_logo,
o.kind AS org_kind,
tm.org_id AS team_org_id,
tm.name AS team_name,
-- Whether the referenced record is itself visible. An entry must not
-- outlive the thing it points at being unpublished — a draft event
-- would otherwise leak its title and date onto a public page. Null
-- for a standalone milestone, which answers to nothing but its own
-- is_published.
CASE t.ref_kind
WHEN 'event' THEN e.is_published
WHEN 'organization' THEN o.is_published
WHEN 'person' THEN p.is_published
WHEN 'team' THEN tm.is_published
ELSE NULL
END AS ref_is_published,
t.occurred_on,
t.title AS title_override
FROM timeline_entries t
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
AND pa.id = (SELECT MIN(id) FROM person_awards
WHERE award_id = t.ref_id);
PRAGMA user_version = 7;

View file

@ -0,0 +1,85 @@
-- ═══════════════════════════════════════════════════════════════
-- 008 v_timeline
--
-- 007's tables, indexes and all eight triggers landed; its view did
-- not. This file creates it, and nothing else.
--
-- The definition below is byte-identical to the one at the foot of
-- 007. That is deliberate: a fresh database built from 007 and an
-- existing one upgraded through 008 must end up with the same view,
-- or a restore from backup six months from now produces a subtly
-- different site. Leave 007 exactly as it is.
--
-- No BEGIN...END anywhere in this file — two plain statements and a
-- pragma — so a runner that splits on semicolons treats it the same
-- way one that doesn't would. 007's triggers are the only place in
-- the schema where that distinction bites, and they are already in.
--
-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and
-- dropping a view touches no data.
--
-- PRAGMA user_version; -- reads 7 before this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_timeline;
CREATE VIEW v_timeline AS
SELECT
t.id,
t.kind,
t.ref_kind,
t.ref_id,
t.precision,
t.is_featured,
t.is_published,
t.sort_order,
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
COALESCE(
t.title,
e.title,
o.name,
aw.name,
p.display_name,
tm.name
) AS effective_title,
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
AS effective_blurb,
t.meta,
t.link_url,
-- Filename only. The directory is the frontend's business.
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
AS effective_logo,
o.kind AS org_kind,
tm.org_id AS team_org_id,
tm.name AS team_name,
-- Whether the referenced record is itself visible. An entry must not
-- outlive the thing it points at being unpublished — a draft event
-- would otherwise leak its title and date onto a public page. Null
-- for a standalone milestone, which answers to nothing but its own
-- is_published.
CASE t.ref_kind
WHEN 'event' THEN e.is_published
WHEN 'organization' THEN o.is_published
WHEN 'person' THEN p.is_published
WHEN 'team' THEN tm.is_published
ELSE NULL
END AS ref_is_published,
t.occurred_on,
t.title AS title_override
FROM timeline_entries t
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
AND pa.id = (SELECT MIN(id) FROM person_awards
WHERE award_id = t.ref_id);
PRAGMA user_version = 8;

View file

@ -0,0 +1,86 @@
-- ═══════════════════════════════════════════════════════════════
-- 009_superadmin.sql
--
-- Adds a third role above 'admin'. A CHECK constraint can't be
-- altered in place, so the table is rebuilt — the recipe from the
-- SQLite docs, in the order it has to happen.
--
-- Foreign keys are OFF for the duration on purpose. `sessions`
-- references admin_users(id), and:
--
-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE
-- CASCADE and empties `sessions` — everyone signed out;
-- * with FKs ON, the RENAME afterwards tries to rewrite the
-- REFERENCES clause in `sessions` and fails, because the table
-- it points at no longer exists.
--
-- With them OFF neither happens: `sessions` keeps pointing at the
-- name "admin_users", which the rename puts back underneath it.
--
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
-- migration runner wraps each file in BEGIN/COMMIT, this file will
-- appear to work and then fail at the rename. Check the runner
-- before applying, or run this one by hand:
--
-- sudo systemctl stop ngu-api
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql
-- sudo systemctl start ngu-api
--
-- Verify after:
--
-- PRAGMA user_version; -- 9
-- PRAGMA foreign_key_check; -- no rows
-- SELECT email, role FROM admin_users;
-- ═══════════════════════════════════════════════════════════════
PRAGMA foreign_keys = OFF;
CREATE TABLE admin_users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Stored lowercased. The application lowercases on every read
-- and write, so the UNIQUE index is genuinely case-insensitive
-- without depending on a collation.
email TEXT NOT NULL UNIQUE,
name TEXT,
-- Nullable so a Google-only account can exist later with no
-- password at all. A row with both can use either route in.
password_hash TEXT,
-- Google's stable subject id. Nullable, unique when present —
-- SQLite allows any number of NULLs in a unique index.
google_sub TEXT UNIQUE,
-- Listed low to high. The application treats these as a ladder,
-- not a set: 'superadmin' passes every check 'admin' passes.
-- The default stays 'admin' — a new account should never arrive
-- at the top of the ladder by accident.
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- Columns listed explicitly rather than SELECT *, so this breaks
-- loudly if the old shape isn't what this file assumes.
INSERT INTO admin_users_new
(id, created_at, email, name, password_hash, google_sub,
role, is_active, last_login_at)
SELECT
id, created_at, email, name, password_hash, google_sub,
role, is_active, last_login_at
FROM admin_users;
DROP TABLE admin_users;
ALTER TABLE admin_users_new RENAME TO admin_users;
-- Informational: prints offending rows and returns nothing if the
-- rebuild left the graph intact.
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;
PRAGMA user_version = 9; -- ← set to this migration's number

View file

@ -0,0 +1,97 @@
-- ═══════════════════════════════════════════════════════════════
-- 010_editor_role.sql
--
-- Adds 'editor' between viewer and admin: can create and update,
-- can't delete.
--
-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's
-- CHECK to the four-role list below, leave its user_version at 8,
-- and throw this file away. Two rebuilds of the same table to
-- reach the same shape is pure risk for no gain.
--
-- Same rebuild as 008, for the same reason: a CHECK constraint
-- can't be altered in place. Foreign keys stay OFF throughout
-- because `sessions` cascades from this table — with them on, the
-- DROP empties your session table and the RENAME then fails.
--
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
-- migration runner wraps each file in BEGIN/COMMIT, this fails at
-- the rename. Same drill as last time:
--
-- sudo systemctl stop ngu-api
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql
-- sudo systemctl start ngu-api
--
-- Verify after:
--
-- PRAGMA user_version; -- 9
-- PRAGMA foreign_key_check; -- no rows
-- SELECT email, role FROM admin_users;
--
-- No existing row changes meaning: an 'admin' stays an 'admin'.
-- Nobody is demoted into the new role automatically, because the
-- accounts that most want it are the ones you'd notice least.
--
-- If a fifth role ever comes up, this is the moment to stop using
-- a CHECK and make `role` an FK to a small admin_roles table —
-- then adding one is an INSERT. Not worth a third rebuild today,
-- since the rank ladder lives in auth.js either way.
-- ═══════════════════════════════════════════════════════════════
PRAGMA foreign_keys = OFF;
CREATE TABLE admin_users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Stored lowercased. The application lowercases on every read
-- and write, so the UNIQUE index is genuinely case-insensitive
-- without depending on a collation.
email TEXT NOT NULL UNIQUE,
name TEXT,
-- Nullable so a Google-only account can exist later with no
-- password at all. A row with both can use either route in.
password_hash TEXT,
-- Google's stable subject id. Nullable, unique when present —
-- SQLite allows any number of NULLs in a unique index.
google_sub TEXT UNIQUE,
-- Listed low to high. The application treats these as a ladder,
-- not a set: each one passes every check the one below it
-- passes. The default stays 'admin' so no existing tooling
-- starts creating accounts with different powers than it did
-- yesterday.
--
-- viewer read
-- editor + create and update
-- admin + delete
-- superadmin + accounts, roles and sessions
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- Columns listed explicitly rather than SELECT *, so this breaks
-- loudly if the old shape isn't what this file assumes.
INSERT INTO admin_users_new
(id, created_at, email, name, password_hash, google_sub,
role, is_active, last_login_at)
SELECT
id, created_at, email, name, password_hash, google_sub,
role, is_active, last_login_at
FROM admin_users;
DROP TABLE admin_users;
ALTER TABLE admin_users_new RENAME TO admin_users;
-- Informational: prints offending rows, returns nothing if the
-- rebuild left the graph intact.
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;
PRAGMA user_version = 10; -- ← set to this migration's number

View file

@ -0,0 +1,30 @@
-- ═══════════════════════════════════════════════════════════════
-- 011 AWARDS CAN BE DRAFTED
--
-- awards was written when an award was a line on a person's
-- record: created, named, done. Now each one has a URL, and
-- there is no way to add a row without it being live the moment
-- it saves.
--
-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award
-- that exists today is already public and backfilling the other
-- way round would take the lot offline.
--
-- After this:
-- · add bool("is_published") to the awards descriptor in
-- admin-schema.js, and the matching checkbox in adminSchema.js
-- (PUBLISH_FIELDS covers both it and sort_order)
-- · add AND a.is_published = 1 to the three award queries in
-- content.js — the /awards list, /awards/:id, and the
-- recipient_count subquery in attachAwards
--
-- PRAGMA user_version; -- was 7 before this file
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE awards
ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1
CHECK (is_published IN (0, 1));
CREATE INDEX awards_published_idx ON awards (is_published, sort_order);
PRAGMA user_version = 11; -- ← set to this migration's number

View file

@ -0,0 +1,135 @@
-- ═══════════════════════════════════════════════════════════════
-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE
--
-- host_org_id said two things that turned out to be wrong: that an
-- event has exactly one host, and that the host is an
-- organization. A retreat can be run jointly by two regions, and
-- some events are one person's.
--
-- Two nullable foreign keys rather than a polymorphic
-- host_kind/host_id pair. There are only ever two kinds, and this
-- way the references stay real and cascade on their own instead of
-- needing the trigger treatment timeline_entries has. CASCADE here
-- does what SET NULL used to do on the column: deleting an
-- organization drops it from the host list and leaves the event
-- standing.
--
-- The first host by sort_order is the one that supplies the logo
-- and colour fallbacks. A person supplies neither — `photo` is a
-- headshot, not a logo, and people have no colour — so an event
-- hosted only by a person and carrying no colour of its own falls
-- through to the section default. That's the view doing nothing
-- rather than a rule anybody has to remember.
--
-- host_org_id stays in place here, unread. 013 drops it: that
-- needs v_events and events_host_idx gone first, and it shouldn't
-- share a deploy with the table replacing it.
--
-- No trigger bodies in this file, so the views can ride along.
--
-- After this:
-- · event_hosts child collection in admin-schema.js and
-- adminSchema.js; the host_org_id field comes out of the
-- events Identity group in both
-- · shapeEvent in content.js emits `hosts`, not `host`
-- · the organization page's hosted-events query joins
-- event_hosts instead of reading host_org_id
-- · eventData.js filters on hosts[], EventDetail renders a list
--
-- PRAGMA user_version; -- reads 11 before this file
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE event_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE,
person_id TEXT REFERENCES people (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
-- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so
-- <> between them is xor.
CHECK ((org_id IS NULL) <> (person_id IS NULL))
) STRICT;
CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order);
CREATE INDEX event_hosts_org_idx ON event_hosts (org_id);
CREATE INDEX event_hosts_person_idx ON event_hosts (person_id);
-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite
-- treats NULLs as distinct, so the same organization could be
-- added twice with the person column null both times. Two partial
-- indexes, one per kind.
CREATE UNIQUE INDEX event_hosts_org_uniq
ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL;
CREATE UNIQUE INDEX event_hosts_person_uniq
ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL;
INSERT INTO event_hosts (event_id, org_id, sort_order)
SELECT id, host_org_id, 0
FROM events
WHERE host_org_id IS NOT NULL;
-- ── Views ──────────────────────────────────────────────────────
-- Every host of every event, resolved to a name and the bits the
-- fallbacks need. is_published travels with the row rather than
-- being filtered here, so the public routes can hide an
-- unpublished host and the admin can still see one.
CREATE VIEW v_event_hosts AS
SELECT
eh.id,
eh.event_id,
eh.sort_order,
CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END
AS host_kind,
COALESCE(eh.org_id, eh.person_id) AS host_id,
COALESCE(o.name, p.display_name) AS host_name,
o.kind AS host_org_kind,
o.logo AS host_logo,
o.color AS host_color,
p.photo AS host_photo,
COALESCE(o.is_published, p.is_published) AS host_is_published
FROM event_hosts eh
LEFT JOIN organizations o ON o.id = eh.org_id
LEFT JOIN people p ON p.id = eh.person_id;
DROP VIEW IF EXISTS v_events;
-- Same contract as before — effective_org_logo, effective_color,
-- effective_status — with the first host standing in for what
-- host_org_id used to be. host_org_id itself is still selected by
-- e.*, and is dead weight until 013 removes it.
--
-- A correlated subquery rather than GROUP BY with bare columns
-- alongside MIN(sort_order): the bare-column form works in SQLite
-- and nowhere else, and it leaves a tie on sort_order resolving
-- differently run to run. At a few dozen events the extra lookup
-- costs nothing worth measuring.
CREATE VIEW v_events AS
SELECT
e.*,
h.host_kind,
h.host_id,
h.host_name,
h.host_org_kind,
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
COALESCE(e.color, h.host_color) AS effective_color,
COALESCE(
e.status,
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
THEN 'past' ELSE 'upcoming' END
) AS effective_status
FROM events e
LEFT JOIN v_event_hosts h
ON h.id = (
SELECT x.id
FROM v_event_hosts x
WHERE x.event_id = e.id
ORDER BY x.sort_order, x.id
LIMIT 1
);
PRAGMA user_version = 12; -- ← set to this migration's number

View file

@ -0,0 +1,48 @@
-- ═══════════════════════════════════════════════════════════════
-- 013 DROP events.host_org_id
--
-- Run this only once 012 is deployed and the site is reading
-- hosts off event_hosts. Until then the column is the rollback:
-- restoring the old v_events is one CREATE VIEW away.
--
-- SQLite refuses DROP COLUMN while the column is named by an index
-- or a view, so both go first and the view comes back unchanged
-- apart from no longer selecting e.host_org_id through e.*. No
-- table rebuild, so no PRAGMA foreign_keys dance.
--
-- Check nothing still reads it before running:
-- grep -rn host_org_id server/src client/src
--
-- PRAGMA user_version; -- reads 12 before this file
-- ═══════════════════════════════════════════════════════════════
DROP INDEX IF EXISTS events_host_idx;
DROP VIEW IF EXISTS v_events;
ALTER TABLE events DROP COLUMN host_org_id;
CREATE VIEW v_events AS
SELECT
e.*,
h.host_kind,
h.host_id,
h.host_name,
h.host_org_kind,
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
COALESCE(e.color, h.host_color) AS effective_color,
COALESCE(
e.status,
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
THEN 'past' ELSE 'upcoming' END
) AS effective_status
FROM events e
LEFT JOIN v_event_hosts h
ON h.id = (
SELECT x.id
FROM v_event_hosts x
WHERE x.event_id = e.id
ORDER BY x.sort_order, x.id
LIMIT 1
);
PRAGMA user_version = 13; -- ← set to this migration's number

View file

@ -0,0 +1,36 @@
-- ═══════════════════════════════════════════════════════════════
-- EVENT TYPE
--
-- What kind of gathering a row is, independent of which band of
-- the Retreats page it appears in. section_id answers "whose is
-- it" — national, regional, partner. event_type answers "what is
-- it", and the two cross freely: a region can run a class, a
-- partner can run a retreat.
--
-- An enum column rather than a lookup table, unlike event_sections.
-- Sections need a table because Retreats.tsx owns presentation
-- keyed on the id, so an unrecognised value makes an event vanish
-- with no error anywhere. A type carries no presentation of its
-- own — an unknown value renders as its own name rather than
-- disappearing — so the CHECK is enough, and the column matches
-- `status` and event_people.role in shape.
--
-- DEFAULT 'retreat' backfills every existing row, which is what
-- they all are. That default is also what lets the admin clear the
-- field: coerceValue omits an empty NOT NULL column rather than
-- writing NULL into it.
--
-- No change to v_events: it is SELECT e.*, so the column arrives on
-- both /events and /events/:id for free.
--
-- No BEGIN...END in this file, so nothing after it is dropped by
-- the migration runner.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE events
ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat'
CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other'));
-- Mirrors events_section_idx: the public list filters on published
-- rows and orders by sort_order, whatever it is narrowing by.
CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order);

View file

@ -0,0 +1,40 @@
-- ═══════════════════════════════════════════════════════════════
-- EVENT SCOPES
--
-- event_sections is a scope list and always was: whose gathering
-- this is, not which band of a page it lands in. The name stuck
-- because for three values those two things coincided. They stop
-- coinciding here — local, international and other are real scopes
-- that Retreats.tsx does not draw a band for.
--
-- Nothing is renamed. events.section_id keeps its name and its
-- foreign key, and this file only touches rows. A column rename
-- would have to walk the descriptors, the shaper, the hook, the
-- section prop and the view, for a word.
--
-- Order is scope order, widest first, with Other last where an
-- unclassified row belongs. Gaps of ten leave room to slot a scope
-- in later without renumbering the ones around it.
--
-- The three UPDATEs correct the existing rows' labels: "National
-- Retreats" was a page heading living in a scope table, and now
-- that a scope can hold a class it reads wrong in the admin's
-- dropdown. Retreats.tsx owns its own band titles and never read
-- these, so nothing on the public site moves.
--
-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand
-- on the box before this shipped, re-running is a no-op instead of
-- a constraint error.
--
-- No BEGIN...END, so nothing after this file is dropped by the
-- migration runner.
-- ═══════════════════════════════════════════════════════════════
UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national';
UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional';
UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner';
INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES
('local', 'Local', 30),
('international', 'International', 40),
('other', 'Other', 60);

View file

@ -63,14 +63,14 @@ entities.get("/:entity/:id", (c) => {
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE); return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
}); });
entities.post("/:entity", requireRole("admin"), async (c) => { entities.post("/:entity", requireRole("editor"), async (c) => {
const entity = entityOr404(c); const entity = entityOr404(c);
const row = createRow(c.get("db"), entity, await json(c)); const row = createRow(c.get("db"), entity, await json(c));
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`); console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
return c.json({ row }, 201, NO_STORE); return c.json({ row }, 201, NO_STORE);
}); });
entities.patch("/:entity/:id", requireRole("admin"), async (c) => { entities.patch("/:entity/:id", requireRole("editor"), async (c) => {
const entity = entityOr404(c); const entity = entityOr404(c);
const id = c.req.param("id"); const id = c.req.param("id");
const row = updateRow(c.get("db"), entity, id, await json(c)); const row = updateRow(c.get("db"), entity, id, await json(c));

View file

@ -93,7 +93,7 @@ admin.get("/feedback", (c) => {
{ status?, admin_note? } — either, both, partial. { status?, admin_note? } — either, both, partial.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
admin.patch("/feedback/:id", requireRole("admin"), async (c) => { admin.patch("/feedback/:id", requireRole("editor"), async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);

View file

@ -5,6 +5,10 @@
GET /events/:id one event, full body, people GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|… GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page GET /organizations/:id one organization's page
GET /teams list, ?org=slug
GET /teams/:id one team's page
GET /awards list, ?org=slug
GET /awards/:id one award and its recipients
Organizations are one table, so they're one endpoint. A region Organizations are one table, so they're one endpoint. A region
and a chapter differ by a handful of fields, which arrive under and a chapter differ by a handful of fields, which arrive under
@ -12,9 +16,33 @@
list component be written once and pointed at any kind. list component be written once and pointed at any kind.
Responses carry their fallbacks already resolved: an event's Responses carry their fallbacks already resolved: an event's
`color` is its own or its host's, and `status` is derived from `color` is its own or its first host's, and `status` is derived
the dates when it isn't set. Components read one field and don't from the dates when it isn't set. Components read one field and
reimplement the rules. don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which
band of the Retreats page an event belongs to, the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
An event's hosts are a list, in billing order, and each one is
either an organization or a person — `kind` says which, and
`org_kind` is there for the three organization routes. The
first is the one the colour and logo fell back to, which is why
order is data and not a display choice.
Two things the team and award routes deliberately don't do:
· /teams/:id carries no roster. /teams/:id/people in people.js
already serves it off v_org_leadership in the shape
PeopleTiles wants, and a second shaper here would be the same
visibility rules written twice, free to drift.
· /awards/:id carries no links and no content blocks. 'award'
is not in the owner_kind CHECK on either polymorphic table,
and widening it is a STRICT table rebuild. `description` is
the prose; the recipients are the page.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono"; import { Hono } from "hono";
@ -34,14 +62,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"];
const marks = (n) => Array(n).fill("?").join(","); const marks = (n) => Array(n).fill("?").join(",");
/* ── Loaders ───────────────────────────────────────────────── */
/* Hosts for a batch of events, keyed by event id, in the order
they're billed. Shaped like loadLinks and loadBlocks so the list
route stays one query per collection rather than one per row.
Unpublished hosts are dropped here rather than in the view: the
admin reads the same view and needs to see them. An event whose
only host is unpublished comes back with an empty list, which is
the right answer — there is nobody to name and nowhere to link. */
function loadHosts(db, ids) {
const byEvent = new Map();
if (ids.length === 0) return byEvent;
const rows = db
.prepare(
`SELECT event_id, host_kind, host_id, host_name, host_org_kind
FROM v_event_hosts
WHERE event_id IN (${marks(ids.length)})
AND host_is_published = 1
ORDER BY event_id, sort_order, id`,
)
.all(...ids);
for (const row of rows) {
const list = byEvent.get(row.event_id) ?? [];
list.push(row);
byEvent.set(row.event_id, list);
}
return byEvent;
}
/* ── Shapers ───────────────────────────────────────────────── */ /* ── Shapers ───────────────────────────────────────────────── */
function shapeEvent(row, links, cardBlocks) { function shapeHost(row) {
return {
kind: row.host_kind, // 'organization' | 'person'
id: row.host_id,
name: row.host_name,
// Which of /regions, /chapters, /partners the slug belongs to.
// Null for a person, whose route needs no disambiguating.
org_kind: row.host_org_kind,
};
}
function shapeEvent(row, links, cardBlocks, hosts = []) {
const { actions, instagram } = splitLinks(links); const { actions, instagram } = splitLinks(links);
return { return {
id: row.id, id: row.id,
section_id: row.section_id, section_id: row.section_id,
event_type: row.event_type,
title: row.title, title: row.title,
theme: row.theme, theme: row.theme,
@ -63,9 +136,7 @@ function shapeEvent(row, links, cardBlocks) {
color: row.effective_color, color: row.effective_color,
gradient: row.gradient, gradient: row.gradient,
host: row.host_org_id hosts: hosts.map(shapeHost),
? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
: null,
description: paragraphs(cardBlocks), description: paragraphs(cardBlocks),
links: actions, links: actions,
@ -124,6 +195,42 @@ function shapeLeader(row) {
}; };
} }
/* teams.org_id is NOT NULL and every query below joins a published
organization, so `org` is never absent. */
function shapeTeam(row, links, cardBlocks) {
const { actions, socials, instagram } = splitLinks(links);
return {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
org: { id: row.org_id, name: row.org_name, kind: row.org_kind },
description: paragraphs(cardBlocks),
links: actions,
socials,
instagram,
};
}
/* awards.org_id is nullable — an award can predate any decision
about which organization owns it — so `org` genuinely can be
null, and is also null when the awarding org is unpublished. */
function shapeAward(row) {
return {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
org: row.org_id && row.org_name
? { id: row.org_id, name: row.org_name, kind: row.org_kind }
: null,
};
}
/* ── Kind-specific details, batched ──────────────────────────── /* ── Kind-specific details, batched ────────────────────────────
Each of these runs a fixed number of queries for the whole list Each of these runs a fixed number of queries for the whole list
rather than one per organization. rather than one per organization.
@ -243,6 +350,84 @@ function attachLeadership(db, orgs) {
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
} }
/* ── Sections that only an organization's own page wants ───────
Called from /organizations/:id and not from the list. A page
needs them; a card doesn't, and the listing shouldn't pay two
queries for something nothing renders.
───────────────────────────────────────────────────────────── */
/* Every team this organization has, including ones with nobody
currently filed under them. `leadership` already carries team_id
and team_name, so the page can group people without this — but
grouping alone would make an empty team invisible rather than
listed, which is the wrong answer for a team that exists. */
function attachTeams(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE org_id IN (${marks(ids.length)}) AND is_published = 1
ORDER BY sort_order, name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
/* The awards this organization gives. awards has no is_published
column, so every row is public the moment it exists — see the
note in the route below. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT a.id, a.org_id, a.name, a.description, a.logo,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
WHERE a.org_id IN (${marks(ids.length)})
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
recipient_count: row.recipient_count,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.awards = byOrg.get(org.id) ?? [];
}
/* ── Events ──────────────────────────────────────────────────── /* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the Flat, with the section ids alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id. section titles and colours and filters this list by section_id.
@ -266,9 +451,15 @@ content.get("/events", (c) => {
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
const links = loadLinks(db, "event", ids); const links = loadLinks(db, "event", ids);
const cards = loadBlocks(db, "event", ids, "card"); const cards = loadBlocks(db, "event", ids, "card");
const hosts = loadHosts(db, ids);
const events = rows.map((row) => const events = rows.map((row) =>
shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), shapeEvent(
row,
links.get(row.id) ?? [],
cards.get(row.id) ?? [],
hosts.get(row.id) ?? [],
),
); );
return json(c, { sections, events }); return json(c, { sections, events });
@ -289,6 +480,7 @@ content.get("/events/:id", (c) => {
const links = loadLinks(db, "event", [id]).get(id) ?? []; const links = loadLinks(db, "event", [id]).get(id) ?? [];
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
const hosts = loadHosts(db, [id]).get(id) ?? [];
const people = db const people = db
.prepare( .prepare(
@ -297,8 +489,36 @@ content.get("/events/:id", (c) => {
) )
.all(id); .all(id);
// Awards presented at this event. person_awards.event_id is the
// only thing that records where a citation was read out, and an
// event page is the one place it reads as news rather than
// trivia.
const awards = db
.prepare(
`SELECT pa.award_id, pa.awarded_on, pa.citation,
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
JOIN awards a ON a.id = pa.award_id
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
)
.all(id)
.map((r) => ({
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
person: { id: r.person_id, name: r.display_name, photo: r.photo },
awarded_on: r.awarded_on,
citation: r.citation,
}));
return json(c, { return json(c, {
event: { ...shapeEvent(row, links, cards), blocks: body, people }, event: {
...shapeEvent(row, links, cards, hosts),
blocks: body,
people,
awards,
},
}); });
}); });
@ -379,19 +599,179 @@ content.get("/organizations/:id", (c) => {
attachRegionDetails(db, one); attachRegionDetails(db, one);
attachChapterDetails(db, one); attachChapterDetails(db, one);
attachLeadership(db, one); attachLeadership(db, one);
attachTeams(db, one);
attachAwards(db, one);
// Everything this organization is hosting or has hosted. // Everything this organization is hosting or has hosted, whether
// on its own or alongside somebody else. Co-hosting counts: an
// event run jointly by two regions belongs on both pages.
organization.events = db organization.events = db
.prepare( .prepare(
`SELECT id, title, date_label, effective_status AS status, `SELECT e.id, e.title, e.date_label, e.event_type,
location_label, event_logo, effective_color AS color e.effective_status AS status,
FROM v_events e.location_label, e.event_logo,
WHERE host_org_id = ? AND is_published = 1 e.effective_color AS color
ORDER BY sort_order`, FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
ORDER BY e.sort_order`,
) )
.all(id); .all(id);
return json(c, { organization }); return json(c, { organization });
}); });
/* ── Teams ─────────────────────────────────────────────────────
GET /teams every published team
GET /teams?org=mid-atlantic one organization's teams
GET /teams/:id one team's page
An unpublished organization hides its teams too, in both
routes. Without that join a retired chapter's board stays
reachable by URL after the chapter itself has gone.
───────────────────────────────────────────────────────────── */
content.get("/teams", (c) => {
const db = c.get("db");
const org = c.req.query("org");
const rows = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""}
ORDER BY o.sort_order, t.sort_order, t.name`,
)
.all(...(org ? [org] : []));
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "team", ids);
const cards = loadBlocks(db, "team", ids, "card");
return json(c, {
teams: rows.map((row) =>
shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
),
});
});
content.get("/teams/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.id = ? AND t.is_published = 1`,
)
.get(id);
if (!row) return c.json({ error: "No such team" }, 404);
const links = loadLinks(db, "team", [id]).get(id) ?? [];
const cards = loadBlocks(db, "team", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "team", [id], "body").get(id) ?? [];
return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } });
});
/* ── Awards ────────────────────────────────────────────────────
GET /awards every award
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the
moment somebody creates it, and there is no way to draft one.
That was fine while awards only appeared as a line on a
person's record; it is thinner ground now that each has a URL.
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
worth doing before this ships.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
const db = c.get("db");
const org = c.req.query("org");
// The count has to apply exactly the visibility rules the detail
// route does, or a card will promise recipients the page then
// doesn't list.
const rows = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
return json(c, {
awards: rows.map((row) => ({
...shapeAward(row),
recipient_count: row.recipient_count,
})),
});
});
content.get("/awards/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`,
)
.get(id);
if (!row) return c.json({ error: "No such award" }, 404);
// The event join is LEFT twice over: person_awards.event_id is
// ON DELETE SET NULL, and the event may since have been
// unpublished. A citation outlives the occasion it was read at.
//
// awarded_on DESC puts undated rows last in SQLite, which is the
// right end for a recipient nobody has dated yet.
const recipients = db
.prepare(
`SELECT pa.person_id, pa.awarded_on, pa.citation,
p.display_name, p.photo, p.tagline,
e.id AS event_id, e.title AS event_title
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
WHERE pa.award_id = ? AND pa.is_public = 1
ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`,
)
.all(id);
return json(c, {
award: {
...shapeAward(row),
recipients: recipients.map((r) => ({
id: r.person_id,
name: r.display_name,
photo: r.photo,
tagline: r.tagline,
awarded_on: r.awarded_on,
citation: r.citation,
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
})),
},
});
});
export default content; export default content;

View file

@ -0,0 +1,217 @@
/* ═══════════════════════════════════════════════════════════════
HISTORY ROUTE — read-only, mounted under /api
GET /history every published timeline entry
v_timeline has already done the resolution: an entry with no title
of its own carries the referenced record's name, an entry with no
date carries the event's starts_on, and org_kind rides along
because /regions, /chapters and /partners are three routes and only
the database knows which a slug is.
What's left here is shaping, and three things the view can't do:
· rosters. A 'people' entry naming a team resolves through
v_org_leadership; one with an editorial list reads
timeline_entry_people. Both are batched, so the number of
queries doesn't grow with the number of entries.
· precision that outruns the date. An entry can hold '2012' with
precision 'day' — the admin doesn't stop you. Trusting that
pair would put the entry in a month node built from a month
that isn't there, so precision is capped at what the string
actually carries.
· entries with no date at all. They can't be placed on a rail, so
they're dropped rather than crashing the page, and counted so
the omission is visible rather than silent.
Filenames only, as everywhere else in this API. Where the images
live is the component's business.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
const history = new Hono();
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const marks = (n) => Array(n).fill("?").join(",");
/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */
function precisionOfString(date) {
const parts = String(date).split("-");
if (parts.length >= 3) return "day";
if (parts.length === 2) return "month";
return "year";
}
const RANK = { year: 0, month: 1, day: 2 };
/* The stored precision is a claim about how much to trust the date. It
can't be more precise than the date itself, and a row that claims
otherwise is a data error the page shouldn't have to survive. */
function effectivePrecision(stored, date) {
const actual = precisionOfString(date);
return RANK[stored] < RANK[actual] ? stored : actual;
}
function shapeEntry(row, rosters) {
const precision = effectivePrecision(row.precision, row.effective_date);
const item = {
id: String(row.id),
date: row.effective_date,
precision,
kind: row.kind,
title: row.effective_title,
featured: row.is_featured === 1,
};
if (row.effective_blurb) item.blurb = row.effective_blurb;
if (row.meta) item.meta = row.meta;
// An explicit link wins on the client too, but sending it only when
// set keeps "no override" distinguishable from "override to empty".
if (row.link_url) item.href = row.link_url;
if (row.ref_kind && row.ref_id) {
item.ref = { kind: row.ref_kind, id: row.ref_id };
// Only organizations need it, and only they have it.
if (row.org_kind) item.ref.orgKind = row.org_kind;
}
if (row.effective_logo && row.ref_kind) {
item.logo = { file: row.effective_logo, kind: row.ref_kind };
}
if (row.ref_kind === "team") {
item.team = {
id: row.ref_id,
name: row.team_name,
orgId: row.team_org_id ?? undefined,
};
}
const people = rosters.get(row.id);
if (people?.length) item.people = people;
return item;
}
history.get("/history", (c) => {
const db = c.get("db");
const rows = db
.prepare(
`SELECT * FROM v_timeline
WHERE is_published = 1
-- A standalone milestone reports null here and is unaffected.
AND (ref_is_published IS NULL OR ref_is_published = 1)
AND effective_date IS NOT NULL
ORDER BY effective_date DESC, sort_order, id`,
)
.all();
// How many entries exist but can't be placed. Worth knowing about —
// an entry nobody gave a date to is invisible, and silence is how it
// stays that way.
const undated = db
.prepare(
`SELECT COUNT(*) AS n FROM v_timeline
WHERE is_published = 1 AND effective_date IS NULL`,
)
.get().n;
const rosters = loadRosters(db, rows);
return json(c, {
items: rows.map((row) => shapeEntry(row, rosters)),
undated,
});
});
/* ── Rosters ───────────────────────────────────────────────────
Two queries total, whatever the number of entries. A team entry
reads the team's current public membership; anything else reads
the entry's own curated list.
───────────────────────────────────────────────────────────── */
function loadRosters(db, rows) {
const rosters = new Map();
const teamEntries = rows.filter(
(row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id,
);
const listEntries = rows.filter(
(row) => row.kind === "people" && row.ref_kind !== "team",
);
if (teamEntries.length) {
const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))];
// v_org_leadership already decides who counts as current and
// public — affiliation still open, marked public, person
// published. Restating those conditions here is how they drift.
const members = db
.prepare(
`SELECT team_id, person_id, display_name, photo, title
FROM v_org_leadership
WHERE team_id IN (${marks(teamIds.length)})
ORDER BY is_owner DESC, sort_order,
COALESCE(sort_name, display_name)`,
)
.all(...teamIds);
const byTeam = new Map();
for (const member of members) {
const list = byTeam.get(member.team_id) ?? [];
list.push({
id: member.person_id,
name: member.display_name,
...(member.photo ? { photo: member.photo } : {}),
...(member.title ? { title: member.title } : {}),
});
byTeam.set(member.team_id, list);
}
for (const row of teamEntries) {
const list = byTeam.get(row.ref_id);
if (list) rosters.set(row.id, list);
}
}
if (listEntries.length) {
const ids = listEntries.map((row) => row.id);
const listed = db
.prepare(
`SELECT tep.entry_id, tep.person_id, tep.note,
p.display_name, p.photo
FROM timeline_entry_people tep
JOIN people p ON p.id = tep.person_id AND p.is_published = 1
WHERE tep.entry_id IN (${marks(ids.length)})
ORDER BY tep.entry_id, tep.sort_order`,
)
.all(...ids);
for (const person of listed) {
const list = rosters.get(person.entry_id) ?? [];
list.push({
id: person.person_id,
name: person.display_name,
...(person.photo ? { photo: person.photo } : {}),
// The note is the person's standing in this entry, which is
// what `title` means on the client.
...(person.note ? { title: person.note } : {}),
});
rosters.set(person.entry_id, list);
}
}
return rosters;
}
export default history;

215
server/src/routes/panel.js Normal file
View file

@ -0,0 +1,215 @@
/* ═══════════════════════════════════════════════════════════════
PANEL ROUTES — server/src/routes/panel.js
GET /api/admin/panel/overview
PATCH /api/admin/panel/users/:id role, is_active
DELETE /api/admin/panel/users/:id/sessions sign out everywhere
Everything here is superadmin-only, enforced once at the top
rather than per route — there's no read here that an ordinary
admin should have either. Account records and live session
counts are a different class of thing from content.
Two rules run through the writes, both about not locking
everyone out of the building:
* nobody edits their own role or active flag, so a misclick
can't demote the person making it;
* the last active superadmin can't be demoted or disabled.
Changing a role or disabling an account drops that person's
live sessions immediately, the same way admin-cli.js does.
Leaving a 30-day cookie valid after revoking the access it
represents is the whole point of having the button.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { requireAuth, requireRole, ROLES } from "../auth.js";
const panel = new Hono();
panel.use("*", requireAuth);
panel.use("*", requireRole("superadmin"));
const NO_STORE = { "Cache-Control": "no-store" };
/* The counts on the overview. Table name is a literal from this
list, never anything off the wire. */
const CONTENT_TABLES = [
["Events", "events"],
["Organizations", "organizations"],
["People", "people"],
["Teams", "teams"],
["Awards", "awards"],
["Timeline entries", "timeline_entries"],
["Feedback", "feedback"],
];
/* ── GET /api/admin/panel/overview ─────────────────────────────── */
panel.get("/overview", (c) => {
const db = c.get("db");
const users = db
.prepare(
`SELECT u.id, u.email, u.name, u.role, u.is_active,
u.created_at, u.last_login_at,
(SELECT COUNT(*) FROM sessions s
WHERE s.user_id = u.id
AND s.expires_at > datetime('now')) AS sessions
FROM admin_users u
ORDER BY u.role DESC, u.email`,
)
.all();
const content = CONTENT_TABLES.map(([label, table]) => ({
label,
count: count(db, table),
}));
return c.json(
{
system: {
schemaVersion: db.prepare("PRAGMA user_version").get().user_version,
dbPath: process.env.DB_PATH ?? null,
nodeVersion: process.version,
platform: `${process.platform} ${process.arch}`,
uptimeSeconds: Math.round(process.uptime()),
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
sessions: count(db, "sessions", "expires_at > datetime('now')"),
roles: ROLES,
},
content,
users,
},
200,
NO_STORE,
);
});
/* A table that hasn't been created yet shouldn't take the whole
page down — the panel is where you go when something is wrong. */
function count(db, table, where) {
try {
const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`;
return db.prepare(sql).get().n;
} catch {
return null;
}
}
/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */
panel.patch("/users/:id", async (c) => {
const db = c.get("db");
const me = c.get("user");
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
if (id === me.id) {
return c.json(
{ error: "You can't change your own role or access. Ask another superadmin." },
403,
);
}
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
const target = db
.prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?")
.get(id);
if (!target) return c.json({ error: "No such account." }, 404);
const sets = [];
const params = [];
const losingSuper =
target.role === "superadmin" &&
((body.role !== undefined && body.role !== "superadmin") ||
(body.is_active !== undefined && Number(body.is_active) === 0));
if (losingSuper && activeSupers(db) <= 1) {
return c.json(
{ error: "That's the last active superadmin. Promote someone else first." },
409,
);
}
if (body.role !== undefined) {
if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422);
sets.push("role = ?");
params.push(body.role);
}
if (body.is_active !== undefined) {
sets.push("is_active = ?");
params.push(Number(body.is_active) ? 1 : 0);
}
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
const tx = db.transaction(() => {
db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run(
...params,
id,
);
// Whatever changed, the access they're holding no longer
// matches the row. Make them sign in again.
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
});
tx();
console.log(
`account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`,
);
return c.json({ user: userRow(db, id) }, 200, NO_STORE);
});
/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */
panel.delete("/users/:id/sessions", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id);
if (!target) return c.json({ error: "No such account." }, 404);
const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
console.log(
`${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`,
);
return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE);
});
function activeSupers(db) {
return db
.prepare(
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
)
.get().n;
}
function userRow(db, id) {
return db
.prepare(
`SELECT u.id, u.email, u.name, u.role, u.is_active,
u.created_at, u.last_login_at,
(SELECT COUNT(*) FROM sessions s
WHERE s.user_id = u.id
AND s.expires_at > datetime('now')) AS sessions
FROM admin_users u WHERE u.id = ?`,
)
.get(id);
}
export default panel;

View file

@ -4,6 +4,7 @@ import Layout from "./components/Layout.tsx";
/*Libraries*/ /*Libraries*/
import { AuthProvider, RequireAuth } from "./lib/auth.tsx"; import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
import RequireRole from "./pages/admin/RequireRole.tsx";
/*Primary Pages*/ /*Primary Pages*/
import Home from "./pages/Home.tsx"; import Home from "./pages/Home.tsx";
@ -11,14 +12,23 @@ import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx"; import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx"; import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx"; import Resources from "./pages/Resources.tsx";
import History from "./pages/History.tsx";
/*Secondary Pages*/ /*Secondary Pages*/
import Feedback from "./pages/Feedback.tsx"; import Feedback from "./pages/Feedback.tsx";
import Giving from "./pages/Giving.tsx"; import Giving from "./pages/Giving.tsx";
/*Entity Pages*/
import EventDetail from './pages/EventDetail.tsx'
import OrganizationDetail from './pages/OrganizationDetail.tsx'
import TeamDetail from './pages/TeamDetail.tsx'
import AwardDetail from './pages/AwardDetail.tsx'
/*Admin Pages*/ /*Admin Pages*/
import AdminLayout from "./pages/admin/AdminLayout.tsx"; import AdminLayout from "./pages/admin/AdminLayout.tsx";
import AdminLogin from "./pages/admin/AdminLogin.tsx"; import AdminLogin from "./pages/admin/AdminLogin.tsx";
import AdminPanel from "./pages/admin/AdminPanel.tsx";
import AdminHome from "./pages/admin/AdminHome.tsx";
import AdminFeedback from "./pages/admin/AdminFeedback.tsx"; import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
import EntityList from "./pages/admin/EntityList.tsx"; import EntityList from "./pages/admin/EntityList.tsx";
import EntityEdit from "./pages/admin/EntityEdit.tsx"; import EntityEdit from "./pages/admin/EntityEdit.tsx";
@ -36,9 +46,17 @@ export default function App() {
<Route element={<Layout />}> <Route element={<Layout />}>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="retreats" element={<Retreats />} /> <Route path="retreats" element={<Retreats />} />
<Route path="/events/:id" element={<EventDetail />} />
<Route path="community" element={<Community />} /> <Route path="community" element={<Community />} />
<Route path="/regions/:id" element={<OrganizationDetail />} />
<Route path="/chapters/:id" element={<OrganizationDetail />} />
<Route path="/partners/:id" element={<OrganizationDetail />} />
<Route path="/organizations/:id" element={<OrganizationDetail />} />
<Route path="/teams/:id" element={<TeamDetail />} />
<Route path="/awards/:id" element={<AwardDetail />} />
<Route path="leadership" element={<Leadership />} /> <Route path="leadership" element={<Leadership />} />
<Route path="resources" element={<Resources />} /> <Route path="resources" element={<Resources />} />
<Route path="history" element={<History />} />
<Route path="feedback" element={<Feedback />} /> <Route path="feedback" element={<Feedback />} />
<Route path="give" element={<Giving />} /> <Route path="give" element={<Giving />} />
<Route path="privacy" element={<Privacy />} /> <Route path="privacy" element={<Privacy />} />
@ -49,7 +67,11 @@ export default function App() {
<Route path="/admin/login" element={<AdminLogin />} /> <Route path="/admin/login" element={<AdminLogin />} />
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="/admin" element={<AdminLayout />}> <Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/feedback" replace />} /> <Route index element={<Navigate to="/admin/home" replace />} />
<Route path="home" element={<AdminHome />} />
<Route element={<RequireRole role="superadmin" />}>
<Route path="panel" element={<AdminPanel />} />
</Route>
<Route path="feedback" element={<AdminFeedback />} /> <Route path="feedback" element={<AdminFeedback />} />
<Route path=":entity" element={<EntityList />} /> <Route path=":entity" element={<EntityList />} />
<Route path=":entity/:id" element={<EntityEdit />} /> <Route path=":entity/:id" element={<EntityEdit />} />

View file

@ -0,0 +1,189 @@
/* ═══════════════════════════════════════════════════════════════
CONTENT BLOCKS
Renders the `body` slot of content_blocks. Events, organizations
and teams all own blocks and all render them the same way, so
this is written once and tinted by the caller's accent.
⚠ Assumes shape.js's loadBlocks returns rows carrying `type`,
`text`, `media`, `href` and an `items` array. If the field names
differ, this is the only file to fix — nothing else reads a
block.
An unknown `type` renders its text as a paragraph rather than
disappearing. A block somebody typed into the admin should be
visible even if the renderer hasn't caught up with it.
═══════════════════════════════════════════════════════════════ */
import { Link } from 'react-router-dom'
import { blockMedia } from '../lib/media.ts'
import type { ContentBlock } from '../lib/useContent.ts'
const BODY = '#4a6b72'
/** External if it has a scheme or starts with //; otherwise it's
* one of our own routes and should go through the router. */
const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)
function Anchor({
href,
children,
className,
style,
}: {
href: string
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
if (isExternal(href)) {
return (
<a href={href} target="_blank" rel="noreferrer" className={className} style={style}>
{children}
</a>
)
}
return (
<Link to={href} className={className} style={style}>
{children}
</Link>
)
}
export default function ContentBlocks({
blocks,
accent = '#138ba0',
className = '',
}: {
blocks?: ContentBlock[] | null
accent?: string
className?: string
}) {
if (!blocks?.length) return null
return (
<div className={`space-y-6 ${className}`}>
{blocks.map((block, index) => (
<Block key={block.id ?? index} block={block} accent={accent} />
))}
</div>
)
}
function Block({ block, accent }: { block: ContentBlock; accent: string }) {
const text = block.text ?? ''
switch (block.type) {
case 'heading':
return (
<h3 className="text-2xl font-bold" style={{ color: accent }}>
{text}
</h3>
)
case 'subheading':
return (
<h4 className="text-lg font-semibold" style={{ color: accent }}>
{text}
</h4>
)
case 'list':
return (
<ul className="space-y-2">
{(block.items ?? []).map((item, index) => (
<li key={index} className="flex gap-3">
<span aria-hidden="true" style={{ color: accent }}>
•
</span>
<span style={{ color: BODY }}>
{item.url ? (
<Anchor href={item.url} className="underline" style={{ color: accent }}>
{item.text}
</Anchor>
) : (
item.text
)}
{item.detail && (
<span className="opacity-70"> — {item.detail}</span>
)}
</span>
</li>
))}
</ul>
)
case 'links':
return (
<div className="flex flex-wrap gap-3">
{(block.items ?? [])
.filter((item) => item.url)
.map((item, index) => (
<Anchor
key={index}
href={item.url as string}
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: accent, color: accent }}
>
{item.text}
</Anchor>
))}
</div>
)
case 'quote':
return (
<blockquote
className="border-l-2 pl-5 text-lg italic"
style={{ borderColor: accent, color: BODY }}
>
{text}
</blockquote>
)
case 'image': {
const src = blockMedia(block.media)
if (!src) return null
const img = (
<img
src={src}
// A caption is a caption, not alt text — but an image with
// neither is decorative, and alt="" is the correct answer
// for that rather than a filename read aloud.
alt={text}
loading="lazy"
decoding="async"
className="w-full rounded-lg"
/>
)
return (
<figure>
{block.href ? <Anchor href={block.href}>{img}</Anchor> : img}
{text && (
<figcaption className="mt-2 text-sm opacity-70" style={{ color: BODY }}>
{text}
</figcaption>
)}
</figure>
)
}
case 'divider':
return <hr className="border-0 h-px" style={{ background: accent, opacity: 0.3 }} />
case 'paragraph':
default:
if (!text) return null
return (
<p className="leading-relaxed" style={{ color: BODY }}>
{block.href ? (
<Anchor href={block.href} className="underline" style={{ color: accent }}>
{text}
</Anchor>
) : (
text
)}
</p>
)
}
}

View file

@ -0,0 +1,91 @@
/* ═══════════════════════════════════════════════════════════════
PAGE STATE
The three ways a detail page can fail to be a detail page. All
four of them need the same thing, and it should be the same thing
— a visitor who hits a dead retreat link and a dead chapter link
shouldn't get two different pages.
Rendered inside PageShell so the chrome doesn't flicker in and
out between loading and loaded.
A 404 gets no retry button: the slug doesn't exist and trying
again won't change that. Anything else does, because a dropped
connection is the usual cause and one tap fixes it.
═══════════════════════════════════════════════════════════════ */
import { Link } from 'react-router-dom'
import PageShell from './PageShell.tsx'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function PageState({
loading,
error,
notFound,
onRetry,
noun,
backTo,
backLabel,
}: {
loading: boolean
error: string | null
notFound: boolean
onRetry: () => void
/** Lowercase, as it appears mid-sentence: "retreat", "chapter". */
noun: string
backTo: string
backLabel: string
}) {
let title: string
let content: React.ReactNode
if (notFound) {
title = 'Not found'
content = (
<div className="max-w-6xl mx-auto px-6">
<p style={{ color: BODY }}>
There’s no {noun} at this address. It may have been renamed, or taken
down.
</p>
<Link
to={backTo}
className="mt-4 inline-block rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: TEAL, color: TEAL }}
>
{backLabel}
</Link>
</div>
)
} else if (error) {
title = 'Something went wrong'
content = (
<div className="max-w-6xl mx-auto px-6">
<p style={{ color: '#b3261e' }}>Couldn’t load this {noun}. {error}</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: TEAL, color: TEAL }}
>
Try again
</button>
</div>
)
} else {
title = 'Loading…'
content = (
<p className="max-w-6xl mx-auto px-6" style={{ color: BODY }} role="status">
Loading this {noun}…
</p>
)
}
return (
<PageShell
title={title}
sections={[{ id: 'status', title: '', accent: TEAL, background: '#ffffff', content }]}
/>
)
}

View file

@ -57,6 +57,9 @@ const inputLocked =
/* ── Dotted paths ────────────────────────────────────────────── */ /* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) { export function getPath(object, path) {
// An entity with no slug has no heading path either, and a missing
// path should read as "no value" rather than throwing on .split.
if (!path) return undefined;
return path.split(".").reduce((value, key) => value?.[key], object); return path.split(".").reduce((value, key) => value?.[key], object);
} }

View file

@ -7,10 +7,17 @@
narrows the result to what it shows. narrows the result to what it shows.
useEvents({ section: "national" }) one band useEvents({ section: "national" }) one band
useEvents({ host: "northwest" }) a region's own events useEvents({ host: "northwest" }) one host's events
useEvents({ status: "upcoming" }) a home page strip useEvents({ status: "upcoming" }) a home page strip
useEvents({ type: "workshop" }) one kind, wherever it is
useEvents({ type: ["class", "workshop"] })
useEvents() everything useEvents() everything
`section` and `type` are different questions and stack rather
than overlap: the section is which band of the page an event
belongs to, the type is what kind of gathering it is. A regional
class matches both { section: "regional" } and { type: "class" }.
Filtering here rather than in the query keeps the endpoint to Filtering here rather than in the query keeps the endpoint to
one cached response. At a few dozen events that's the right one cached response. At a few dozen events that's the right
trade; if the list ever runs to hundreds, move the filters into trade; if the list ever runs to hundreds, move the filters into
@ -23,18 +30,30 @@ import { useResource } from "../lib/useResource.js";
const EMPTY = { events: [] }; const EMPTY = { events: [] };
export function useEvents({ section, host, status } = {}) { export function useEvents({ section, host, status, type } = {}) {
const { data, error, loading } = useResource("/events", { fallback: EMPTY }); const { data, error, loading } = useResource("/events", { fallback: EMPTY });
const all = data?.events; const all = data?.events;
/* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency
list something stable to compare. */
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
const events = useMemo(() => { const events = useMemo(() => {
let list = all ?? []; let list = all ?? [];
if (section) list = list.filter(e => e.section_id === section); if (section) list = list.filter(e => e.section_id === section);
if (host) list = list.filter(e => e.host?.id === host); // `host` is an organization or a person slug, and an event can
// have several of either — co-hosting puts one event on both
// hosts' lists, which is the point.
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
if (status) list = list.filter(e => e.status === status); if (status) list = list.filter(e => e.status === status);
if (typeKey) {
const wanted = new Set(typeKey.split(","));
list = list.filter(e => wanted.has(e.event_type));
}
return list; return list;
}, [all, section, host, status]); }, [all, section, host, status, typeKey]);
return { events, loading, error }; return { events, loading, error };
} }
@ -52,3 +71,12 @@ export function splitByStatus(events = []) {
return { upcoming, past }; return { upcoming, past };
} }
/* Which of the declared types a list actually contains, in
EVENT_TYPES order rather than whatever order the rows arrived
in. A section with one type has nothing to filter, which is what
lets the chip bar hide itself. */
export function typesPresent(events = [], declared = []) {
const seen = new Set(events.map(e => e.event_type));
return declared.filter(entry => seen.has(entry.id));
}

View file

@ -0,0 +1,63 @@
/**
* Decade headers for the history page.
*
* Not in the database on purpose. There are four of them, they change
* about never, and they're editorial voice rather than record — the same
* reasoning that keeps the map grid config in code. A table for four
* rows that nobody edits is a migration and an admin page for nothing.
*
* `preProgram` is what draws the gap: a dashed rail, hollow year dots,
* and the "before the program" marker. It lives on the decade rather
* than being a hardcoded year check, so the gap moves if the founding
* date is ever revised.
*/
import type { DecadeMeta } from '../lib/timeline'
export const HISTORY_DECADES: DecadeMeta[] = [
{
decade: 2020,
title: 'The Age of Autonomy',
tagline: 'Growth, unprecedented support, returning to our roots',
blurb:
'Covid-19 sends everyone home for 2 years and from the resurgence comes a new iteration of the program',
},
{
decade: 2010,
title: 'Millennials run the show',
tagline: 'A time of something',
blurb:
'Need to update what happened here, mostly documented on facebook we asumme',
},
{
decade: 2000,
title: 'NGU - The new acronym',
tagline: 'Turn of the century forms a national explosion',
blurb:
'Unity\'s young adult program becomes Next Generation of Unity',
},
{
decade: 1990,
title: 'Before NGU there was YAU',
tagline: 'Young adult ministry existed',
preProgram: true,
blurb:
'Anything listed here predates our named program. We are looking to document this history more',
},
{
decade: 1980,
title: 'YAU?',
tagline: 'Young adult ministry existed',
preProgram: true,
blurb:
'Anything listed here predates our named program. We are looking to document this history more',
},
{
decade: 1970,
title: 'YAU?',
tagline: 'Young adult ministry existed',
preProgram: true,
blurb:
'Anything listed here predates our named program. We are looking to document this history more',
},
]

299
src/data/old-historyMock.ts Normal file
View file

@ -0,0 +1,299 @@
/**
* PLACEHOLDER DATA — delete this file once `GET /api/history` lands.
*
* Shaped exactly like the aggregation route's payload, so swapping it out
* is a two-line change in History.tsx.
*
* Note what is and isn't here. Entries that point at a record carry a
* `ref` and little else: no venue, no host name, no copy that also lives
* in `events`. The few that do carry a title are overriding it on
* purpose. Titles and blurbs below are invented scaffolding, not real NGU
* history — replace them before anyone sees this.
*
* Decade headers are not here — they live in historyDecades.ts and stay
* in code even after the API lands.
*
* Upcoming items are not flagged. `partitionByDate` decides what's
* upcoming by comparing dates to the wall clock, so nothing here needs
* editing as dates pass.
*/
import type { TimelineItem } from '../lib/timeline'
export const MOCK_ITEMS: TimelineItem[] = [
// ——— Upcoming (relative to the wall clock, not a flag) ———
{
id: 'tl-0001',
date: '2027-06',
precision: 'month',
kind: 'event',
title: 'Summer Conference 2027',
meta: 'Unity Village, MO',
ref: { kind: 'event', id: 'summer-2027' },
logo: { file: 'summer-conference.svg', kind: 'event' },
},
{
id: 'tl-0002',
date: '2026-11-14',
precision: 'day',
kind: 'event',
title: 'Fall Regional Rally',
meta: 'Southeast',
ref: { kind: 'event', id: 'fall-rally-2026' },
logo: { file: 'regional-rally.svg', kind: 'event' },
},
{
id: 'tl-0003',
date: '2026-10-03',
precision: 'day',
kind: 'event',
title: 'Chapter Leads Intensive',
meta: 'Online',
ref: { kind: 'event', id: 'leads-intensive-2026' },
},
// ——— 2020s ———
{
id: 'tl-0010',
date: '2026-03',
precision: 'month',
kind: 'organization',
title: 'Twentieth chapter chartered',
blurb:
'The first new charter in the region since 2017, started by three people who met at a rally two summers earlier.',
featured: true,
ref: { kind: 'organization', id: 'boise', orgKind: 'chapter' },
logo: { file: 'boise.svg', kind: 'organization' },
},
{
id: 'tl-0011',
date: '2026-04-17',
precision: 'day',
kind: 'event',
title: 'Spring Regional Rally',
ref: { kind: 'event', id: 'spring-rally-2026' },
logo: { file: 'regional-rally.svg', kind: 'event' },
},
{
id: 'tl-0012',
date: '2026-01',
precision: 'month',
kind: 'people',
title: 'New leadership team seated',
blurb: 'A four-person exec on a two-year term, the first under the 2024 bylaws.',
ref: { kind: 'team', id: 'ngu-leadership' },
team: { id: 'ngu-leadership', name: 'Leadership Team', orgId: 'ngu', orgName: 'NGU National' },
people: [
{ id: 'jane-doe', name: 'Jane Doe', title: 'Chair', photo: 'jane-doe.jpg' },
{ id: 'sam-ruiz', name: 'Sam Ruiz', title: 'Vice Chair', photo: 'sam-ruiz.jpg' },
{ id: 'ada-mensah', name: 'Ada Mensah', title: 'Secretary' },
{ id: 'tom-baird', name: 'Tom Baird', title: 'Treasurer', photo: 'tom-baird.jpg' },
],
},
{
id: 'tl-0013',
date: '2025-12-28',
precision: 'day',
kind: 'event',
title: 'Winter Gathering',
ref: { kind: 'event', id: 'winter-gathering-2025' },
},
{
id: 'tl-0014',
date: '2025-07-19',
precision: 'day',
kind: 'award',
title: 'Service Award',
meta: 'Presented to the Southeast chapter leads',
ref: { kind: 'award', id: 'service' },
logo: { file: 'service-award.svg', kind: 'award' },
},
{
id: 'tl-0015',
date: '2025-07-17',
precision: 'day',
kind: 'event',
title: 'Summer Conference',
meta: 'Three days, 140 attendees',
ref: { kind: 'event', id: 'summer-2025' },
logo: { file: 'summer-conference.svg', kind: 'event' },
},
{
id: 'tl-0016',
date: '2025',
precision: 'year',
kind: 'milestone',
title: 'Photo archive digitized',
blurb: 'Roughly 4,000 images from 2003 onward, scanned by volunteers.',
href: 'https://archive.nextgenerationofunity.org',
},
{
id: 'tl-0017',
date: '2024-09',
precision: 'month',
kind: 'milestone',
title: 'Bylaws rewritten',
blurb:
'Term limits, a defined handoff window, and the first written process for chartering a chapter.',
featured: true,
},
{
id: 'tl-0018',
date: '2024-07-11',
precision: 'day',
kind: 'event',
title: 'Summer Conference',
ref: { kind: 'event', id: 'summer-2024' },
logo: { file: 'summer-conference.svg', kind: 'event' },
},
{
id: 'tl-0019',
date: '2024-07-13',
precision: 'day',
kind: 'award',
title: 'Emerging Leader Award',
meta: 'First year the award was given',
ref: { kind: 'award', id: 'emerging-leader' },
logo: { file: 'emerging-leader.svg', kind: 'award' },
},
{
id: 'tl-0020',
date: '2022-06',
precision: 'month',
kind: 'event',
title: 'First in-person rally since 2019',
blurb: 'Sixty-one people, most of whom had only ever met on a video call.',
featured: true,
ref: { kind: 'event', id: 'return-rally-2022' },
},
{
id: 'tl-0021',
date: '2021-08',
precision: 'month',
kind: 'event',
title: 'Online Summer Intensive',
meta: 'Six sessions across two weeks',
ref: { kind: 'event', id: 'online-intensive-2021' },
},
{
id: 'tl-0022',
date: '2020-03',
precision: 'month',
kind: 'milestone',
title: 'All gatherings suspended',
blurb: 'Weekly online rooms started the following week and ran for 118 weeks.',
},
// ——— 2010s ———
{
id: 'tl-0030',
date: '2018-05',
precision: 'month',
kind: 'organization',
title: 'Eighth region recognized',
featured: true,
ref: { kind: 'organization', id: 'northwest', orgKind: 'region' },
logo: { file: 'northwest.svg', kind: 'organization' },
},
{
id: 'tl-0031',
date: '2018-07-20',
precision: 'day',
kind: 'event',
title: 'Summer Conference',
ref: { kind: 'event', id: 'summer-2018' },
logo: { file: 'summer-conference.svg', kind: 'event' },
},
{
id: 'tl-0032',
date: '2016-02',
precision: 'month',
kind: 'people',
title: 'Retreat Team formed',
blurb:
'Programming had been whoever volunteered. This made it a standing team with a handoff.',
ref: { kind: 'team', id: 'ngu-retreat-team' },
team: { id: 'ngu-retreat-team', name: 'Retreat Team', orgId: 'ngu', orgName: 'NGU National' },
people: [
{ id: 'marcus-hale', name: 'Marcus Hale', title: 'Founding lead', photo: 'marcus-hale.jpg' },
{ id: 'priya-nair', name: 'Priya Nair', photo: 'priya-nair.jpg' },
],
},
{
id: 'tl-0033',
date: '2015-07',
precision: 'month',
kind: 'award',
title: 'First Service Award presented',
blurb: 'Created to name the work that had been going unnamed for a decade.',
featured: true,
ref: { kind: 'award', id: 'service' },
logo: { file: 'service-award.svg', kind: 'award' },
},
{
id: 'tl-0034',
date: '2015-02',
precision: 'month',
kind: 'milestone',
title: 'Shared calendar goes live',
blurb: 'Regions stop scheduling on top of each other.',
},
{
id: 'tl-0035',
date: '2012-06',
precision: 'month',
kind: 'event',
title: 'Summer Conference',
meta: 'The year attendance first passed 100',
ref: { kind: 'event', id: 'summer-2012' },
logo: { file: 'summer-conference.svg', kind: 'event' },
},
{
id: 'tl-0036',
date: '2012',
precision: 'year',
kind: 'milestone',
title: 'Adopted the name Next Generation of Unity',
},
// ——— 2000s ———
{
id: 'tl-0040',
date: '2009',
precision: 'year',
kind: 'milestone',
title: 'Four regions drawn on a map for the first time',
},
{
id: 'tl-0041',
date: '2005-08',
precision: 'month',
kind: 'milestone',
title: 'The gathering becomes annual',
blurb: 'Before this it happened when someone had the energy to organize it.',
featured: true,
},
{
id: 'tl-0042',
date: '2002-08',
precision: 'month',
kind: 'event',
title: 'First young adult retreat',
meta: 'Nineteen attendees',
blurb:
'Organized over six weeks at a borrowed retreat center. Everything else on this page follows from it.',
featured: true,
ref: { kind: 'event', id: 'first-retreat-2002' },
},
// ——— Pre-program ———
{
id: 'tl-0050',
date: '1997',
precision: 'year',
kind: 'milestone',
title: 'Regional youth programs running independently',
blurb:
'Not NGU, and not connected to each other — but the people who started NGU came out of these.',
},
]

View file

@ -32,7 +32,7 @@ export function useOrganizations(kind) {
? `/organizations?kind=${encodeURIComponent(kind)}` ? `/organizations?kind=${encodeURIComponent(kind)}`
: "/organizations"; : "/organizations";
const { data, error, loading } = useResource(path, { fallback: EMPTY }); const { data, error, loading } = useResource(path);
return { return {
organizations: data?.organizations ?? [], organizations: data?.organizations ?? [],

View file

@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [
{ path: "is_public", label: "Public", widget: "checkbox" }, { path: "is_public", label: "Public", widget: "checkbox" },
]; ];
/* The timeline panel that appears on an event or organization once the
box is ticked. Paths are prefixed with the extension key, exactly as
'region.scope' and 'private.notes' are.
Everything here is an override. Left blank, the history page falls
back to the record's own title, date and logo through v_timeline —
which is the point of referencing rather than copying. */
const timelineExtensionFields = (noun) => [
{
path: "timeline.occurred_on",
label: "Date on the timeline",
help: `Blank uses the ${noun}'s own date. Partial dates are fine: 2012, 2012-06`,
},
{
path: "timeline.precision",
label: "Date precision",
widget: "select",
options: ["year", "month", "day"],
help: "How much of the date to trust. 'year' files it under 'Elsewhere in 2012'",
},
{
path: "timeline.title",
label: "Title override",
full: true,
help: `Blank uses the ${noun}'s name`,
},
{
path: "timeline.blurb",
label: "Blurb override",
widget: "textarea",
full: true,
help: `Blank uses the ${noun}'s tagline`,
},
{ path: "timeline.meta", label: "Secondary line", help: "Region, venue, recipient" },
{
path: "timeline.link_url",
label: "Link override",
help: `Blank links to the ${noun}'s own page`,
},
{
path: "timeline.is_featured",
label: "Featured",
widget: "checkbox",
help: "Shown large, above the month list for its year",
},
{ path: "timeline.is_published", label: "Visible on the history page", widget: "checkbox" },
{ path: "timeline.sort_order", label: "Sort order", widget: "number" },
];
/* The checkbox itself, plus the panel it gates. One entry in `groups`. */
const timelineGroup = (noun) => ({
legend: "Timeline",
note:
`Adds this ${noun} to the history page. Nothing is copied — the entry ` +
`reads this record, so editing it here updates the timeline too.`,
fields: [
{
path: "in_timeline",
label: "On the timeline",
widget: "checkbox",
help: "Show this on the history page",
},
],
});
const timelineDetailGroup = (noun) => ({
legend: "Timeline entry",
when: { path: "in_timeline", value: 1 },
note: "Every field here is optional. Blank means \u201cuse the record's own value\u201d.",
fields: timelineExtensionFields(noun),
});
/* The two collections every entity carries. */ /* The two collections every entity carries. */
const linksChild = { const linksChild = {
key: "links", key: "links",
@ -69,6 +141,43 @@ const linksChild = {
], ],
}; };
/* Hosts. One row is one host, ordered, and each is either an
organization or a person — never both, which the CHECK on
event_hosts enforces and this form can only ask nicely about.
The first row supplies the logo and colour when the event sets
neither, so the order here is data rather than a display choice.
A person supplies neither: there is no colour on a person and a
headshot is not a logo, so a person-hosted event with no colour
of its own falls through to the section default. */
const hostsChild = {
key: "event_hosts",
label: "Hosts",
addLabel: "Add host",
title: (row, options) =>
options?.organizations?.find((o) => o.id === row.org_id)?.label ??
options?.people?.find((p) => p.id === row.person_id)?.label ??
"New host",
blank: { org_id: "", person_id: "" },
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
},
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
blankLabel: "— none —",
help: "One or the other, not both. First host supplies the logo and colour.",
},
],
};
const blocksChild = { const blocksChild = {
key: "content_blocks", key: "content_blocks",
label: "Content blocks", label: "Content blocks",
@ -184,6 +293,8 @@ const organizations = {
], ],
}, },
{ legend: "Place", fields: PLACE_FIELDS }, { legend: "Place", fields: PLACE_FIELDS },
timelineGroup("organization"),
timelineDetailGroup("organization"),
{ legend: "Publishing", fields: PUBLISH_FIELDS }, { legend: "Publishing", fields: PUBLISH_FIELDS },
], ],
@ -225,13 +336,19 @@ const events = {
list: { list: {
columns: [ columns: [
{ key: "title", label: "Title", primary: true }, { key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Section" }, { key: "section_id", label: "Scope" },
{ key: "event_type", label: "Type" },
{ key: "date_label", label: "Dates" }, { key: "date_label", label: "Dates" },
{ key: "status", label: "Status" }, { key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" }, { key: "is_published", label: "Live", widget: "bool" },
], ],
filters: [ filters: [
{ key: "section_id", label: "Section", optionsFrom: "event_sections" }, { key: "section_id", label: "Scope", optionsFrom: "event_sections" },
{
key: "event_type",
label: "Type",
options: ["retreat", "class", "workshop", "meeting", "other"],
},
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] }, { key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] }, { key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
], ],
@ -241,20 +358,25 @@ const events = {
{ {
legend: "Identity", legend: "Identity",
fields: [ fields: [
// The column is still section_id — the rows in event_sections
// are what changed, not the schema. Only the label moved,
// because "scope" is what the field has always meant and
// "section" described where it happened to be rendered.
{ {
path: "section_id", path: "section_id",
label: "Section", label: "Scope",
widget: "select", widget: "select",
optionsFrom: "event_sections", optionsFrom: "event_sections",
required: true, required: true,
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
}, },
{ {
path: "host_org_id", path: "event_type",
label: "Host", label: "Type",
widget: "select", widget: "select",
optionsFrom: "organizations", options: ["retreat", "class", "workshop", "meeting", "other"],
blankLabel: "— none —", required: true,
help: "Supplies the logo and colour when this event sets neither", help: "What kind of gathering. Independent of the scope",
}, },
{ path: "title", label: "Title", required: true }, { path: "title", label: "Title", required: true },
{ path: "theme", label: "Theme" }, { path: "theme", label: "Theme" },
@ -290,10 +412,13 @@ const events = {
{ path: "gradient", label: "Gradient", full: true }, { path: "gradient", label: "Gradient", full: true },
], ],
}, },
timelineGroup("event"),
timelineDetailGroup("event"),
{ legend: "Publishing", fields: PUBLISH_FIELDS }, { legend: "Publishing", fields: PUBLISH_FIELDS },
], ],
children: [ children: [
hostsChild,
{ {
key: "event_people", key: "event_people",
label: "People at this event", label: "People at this event",
@ -600,7 +725,160 @@ const awards = {
], ],
}; };
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards }; /* ── Timeline ─────────────────────────────────────────── */
// The only entity with no slug: the table assigns the id, because an
// entry referencing an event has no name of its own and one gets
// created every time somebody ticks a checkbox. `idKind: "auto"` tells
// EntityEdit to show the id rather than ask for it.
//
// Most rows here are created from an event or organization page, not
// this one. What this page is for: hand-authored milestones with no
// record behind them, 'people' entries about a team forming, and fixing
// up the entries the checkboxes made.
const timeline = {
key: "timeline",
label: "Timeline",
singular: "entry",
idLabel: "Entry",
idKind: "auto",
titleFrom: "title",
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "occurred_on", label: "Date" },
{ key: "kind", label: "Kind" },
{ key: "ref_id", label: "References" },
{ key: "is_featured", label: "Featured", widget: "bool" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{
key: "kind",
label: "Kind",
options: ["milestone", "event", "organization", "award", "people"],
},
{
key: "ref_kind",
label: "References",
options: ["event", "organization", "award", "person", "team"],
},
{ key: "is_featured", label: "Featured", options: [["1", "Featured"], ["0", "Normal"]] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "What this is",
note:
"An entry either points at a record or stands on its own. " +
"Pointing at one means its title, date and logo come from that " +
"record \u2014 nothing is copied, so editing the record updates this.",
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["milestone", "event", "organization", "award", "people"],
required: true,
help: "Drives the marker and the layout. 'people' renders a roster",
},
{
path: "ref_kind",
label: "Points at",
widget: "select",
options: ["event", "organization", "award", "person", "team"],
blankLabel: "\u2014 nothing, this stands alone \u2014",
help: "Changing this leaves the record below orphaned \u2014 pick a new one",
},
{
path: "ref_id",
label: "Record",
widget: "select",
optionsFrom: "timeline_refs",
blankLabel: "\u2014 none \u2014",
// One flat list of every referenceable row, narrowed to the
// kind chosen above. Five dropdowns of which four are always
// wrong would be worse.
filterBy: (option, row) => option.kind === row.ref_kind,
help: "Only records of the kind chosen above",
},
],
},
{
legend: "When",
note:
"Blank takes the referenced record's own date. Precision is what " +
"says how much of the date to believe \u2014 a backfilled entry that " +
"only knows the year should say so.",
fields: [
{
path: "occurred_on",
label: "Date",
help: "2012, 2012-06 or 2012-06-14",
},
{
path: "precision",
label: "Precision",
widget: "select",
options: ["year", "month", "day"],
},
],
},
{
legend: "Text",
note: "All optional. Blank uses the referenced record's own wording.",
fields: [
{ path: "title", label: "Title", full: true },
{ path: "blurb", label: "Blurb", widget: "textarea", full: true },
{ path: "meta", label: "Secondary line", help: "Region, venue, recipient" },
{ path: "link_url", label: "Link", help: "Blank links to the record's own page" },
],
},
{
legend: "Publishing",
fields: [
{
path: "is_featured",
label: "Featured",
widget: "checkbox",
help: "Shown large, above the month list for its year",
},
{ path: "is_published", label: "Published", widget: "checkbox" },
{ path: "sort_order", label: "Sort order", widget: "number" },
],
},
],
children: [
{
key: "people",
label: "People",
addLabel: "Add person",
note:
"For an entry about people. A 'people' entry pointing at a team " +
"already shows that team's members \u2014 this is for the cases where " +
"the list is editorial rather than structural.",
title: (row, options) =>
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
blank: { person_id: "" },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
{ path: "note", label: "Note", help: "Founding lead, first chair" },
],
},
],
};
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
export function slugify(value) { export function slugify(value) {
return String(value ?? "") return String(value ?? "")

46
src/lib/eventTypes.ts Normal file
View file

@ -0,0 +1,46 @@
/* ═══════════════════════════════════════════════════════════════
EVENT TYPES
The client half of the CHECK on events.event_type. Order here is
display order — the filter chips read it straight off this array,
so moving a line moves a chip.
What this is not: event_sections. A section owns presentation —
Retreats.tsx keys its title, accent and background on the id, so
an unrecognised section_id makes an event vanish with no error,
which is why that one is a real table with a real foreign key. A
type carries no presentation of its own and an unknown value
renders as its own name, so a CHECK is enough.
Adding a type is three edits: the CHECK in a migration, the enum
in both descriptor halves, and this list. Adding it here alone
means the site offers a filter the database will refuse to store.
═══════════════════════════════════════════════════════════════ */
export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other'
export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [
{ id: 'retreat', label: 'Retreat', plural: 'Retreats' },
{ id: 'class', label: 'Class', plural: 'Classes' },
{ id: 'workshop', label: 'Workshop', plural: 'Workshops' },
{ id: 'meeting', label: 'Meeting', plural: 'Meetings' },
{ id: 'other', label: 'Other', plural: 'Other' },
]
export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id)
const BY_ID = new Map<string, { label: string; plural: string }>(
EVENT_TYPES.map((entry) => [entry.id as string, entry]),
)
const capitalize = (word: string) =>
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
/* A value the CHECK has gained since this file was written renders
as itself rather than vanishing — the same rule EventDetail's
ROLE_ORDER follows for billing roles. */
export const eventTypeLabel = (id?: string | null): string =>
(id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '')
export const eventTypePlural = (id?: string | null): string =>
(id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '')

174
src/lib/hrefs.ts Normal file
View file

@ -0,0 +1,174 @@
/* ═══════════════════════════════════════════════════════════════
PUBLIC HREFS
One place that turns a record into a URL. The API deliberately
never sends paths — it sends `{ kind, id }` and, for an
organization, the `org_kind` that decides which of the three
routes a slug belongs to. Deciding that in each component is how
/regions/x and /chapters/x end up both existing for the same
record, and how the history timeline ended up emitting /event/:id
while the router only knew /retreats/:id.
timelineRefs.ts now delegates here rather than keeping its own
table, so there is one answer to "where does an event live" and
changing it is changing EVENT_BASE below.
navConfig.js stays the source of truth for the *nav*: these are
record routes, which never appear in it.
── On missing ids ──
Every builder takes an id the caller believed it had. When it
doesn't, the old behaviour was to interpolate the string
"undefined" into a path, render a link to it, mount a page and
fetch /api/organizations/undefined — four steps between the
mistake and any sign of it, none of which name the component
that made it.
Now the id is checked here. In dev that's a console.error with a
stack trace pointing at the caller; in production the path still
comes out, because a broken link beats a crashed page, and
useResource refuses to fetch it.
═══════════════════════════════════════════════════════════════ */
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
/* A region, a chapter and a partner read as different things to a
visitor even though they are one table, so they get one route
each. 'national' is NGU itself — one record, no listing to sit
under, so it falls through to the generic path. */
const ORG_BASE: Record<string, string> = {
region: '/regions',
chapter: '/chapters',
partner: '/partners',
national: '/organizations',
}
/** Canonical base for an event page.
*
* /events/:id, not /retreats/:id. The listing page is called
* Retreats because that's what NGU calls the gatherings it hosts,
* but the records are `events`, the API route is /api/events, and
* plenty of them — partner events, conferences — aren't retreats
* at all. Naming the record route after one page's editorial
* framing would have been wrong the first time a non-retreat got
* its own page.
*
* Nothing redirects from /retreats/:id, because nothing ever
* linked there. */
export const EVENT_BASE = '/events'
const DEV = Boolean((import.meta as any)?.env?.DEV)
/* Router params arrive as strings, so an id that has already been
through a template literal shows up as the literal word. Those
are as broken as a genuine null. */
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
export const isBadId = (id: unknown): boolean =>
id == null || BAD.has(String(id))
function checkId(id: unknown, what: string): string {
if (!isBadId(id)) return String(id)
if (DEV) {
// console.error rather than warn: this is always a bug, and the
// stack is the whole point — it names the component that passed
// nothing.
console.error(
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
`The link it returns will 404. Caller:`,
new Error('hrefs: missing id').stack,
)
}
return 'undefined'
}
/**
* The API path for one record, or null when the id isn't usable.
*
* The mirror image of the builders below: they make the URL a
* visitor sees, this makes the URL the client fetches, and both
* have to agree about what counts as an id.
*
* It lives here rather than next to the hook that calls it because
* it is a path, and paths are this file's job — and because a
* component that interpolates a missing route param produces the
* literal string "undefined", which the server cannot tell apart
* from a slug somebody genuinely typed. It answers 404 either way,
* and the log fills with GET /api/organizations/undefined with
* nothing to say where it came from.
*
* Returning null costs a round trip and turns a mystery 404 into
* the not-found page, which is what a visitor should see anyway.
*/
export function detailPath(base: string, id?: string | null): string | null {
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
}
export const eventHref = (id?: string | null) =>
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
export function orgHref(id?: string | null, kind?: string | null): string {
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
}
/* For anything holding a polymorphic reference — timeline entries,
content blocks — where the kind arrives as data rather than being
known at the call site.
Returns null for a kind with no page AND for a reference with no
id, so the caller renders plain text instead of a dead link.
This is the one the timeline wants: an entry whose ref didn't
resolve should read as text, not as a link to nowhere. */
export function refHref(
kind: string | null | undefined,
id: string | null | undefined,
orgKind?: string | null,
): string | null {
if (!kind || isBadId(id)) return null
switch (kind) {
case 'event':
return eventHref(id)
case 'organization':
return orgHref(id, orgKind)
case 'team':
return teamHref(id)
case 'award':
return awardHref(id)
case 'person':
return personHref(id)
default:
return null
}
}
/* What to call the kind in a breadcrumb or a back link. */
export const ORG_KIND_LABEL: Record<string, string> = {
national: 'Next Generation of Unity',
region: 'Region',
chapter: 'Chapter',
partner: 'Partner organization',
}
/* Where "back" goes from a record page. A chapter belongs to
/community, a retreat to /retreats. */
export function orgListHref(kind?: string | null): { to: string; label: string } {
switch (kind) {
case 'region':
return { to: '/community#local', label: 'All regions' }
case 'chapter':
return { to: '/community#local', label: 'All chapters' }
case 'partner':
return { to: '/community#partners', label: 'All partner organizations' }
default:
return { to: '/community', label: 'Community' }
}
}

77
src/lib/media.ts Normal file
View file

@ -0,0 +1,77 @@
/* ═══════════════════════════════════════════════════════════════
MEDIA PATHS
Every image field in the API is a bare filename — "where the
images live is the component's business", as history.js puts it.
This is that business, in one file, so moving a directory is one
edit rather than a grep.
⚠ Only two of these directories are confirmed by the admin help
text: people/ and event-logos/. The other three are a guess at
your convention. Check public/ and fix them here — nothing else
references the paths.
A value that already looks like a path or a URL is returned
untouched, so a hand-written entry can point anywhere.
═══════════════════════════════════════════════════════════════ */
const ABSOLUTE = /^(https?:|\/|data:)/
function inDir(dir: string) {
return (file?: string | null): string | null => {
if (!file) return null
if (ABSOLUTE.test(file)) return file
return `${dir}/${file}`
}
}
export const personPhoto = inDir('/people') // confirmed
export const eventLogo = inDir('/event-logos') // confirmed
export const orgLogo = inDir('/org-logos') // ⚠ guess
export const teamLogo = inDir('/team-logos') // ⚠ guess
export const awardLogo = inDir('/award-logos') // ⚠ guess
/* content_blocks.media, which can be an image on any owner's page,
so it can't share a per-entity directory. */
export const blockMedia = inDir('/media') // ⚠ guess
/* ── By record kind ────────────────────────────────────────────
The timeline sends `logo: { file, kind }` rather than a path,
because v_timeline COALESCEs across five tables and only the
ref_kind says which one the filename came from.
⚠ Three of these directories are the guesses above. Your current
timelineRefs.ts already has the real ones — timeline logos render
today — so copy them into the map above and delete this note.
───────────────────────────────────────────────────────────── */
const BY_KIND: Record<string, (file?: string | null) => string | null> = {
event: eventLogo,
organization: orgLogo,
team: teamLogo,
award: awardLogo,
person: personPhoto,
}
export function logoForKind(
kind?: string | null,
file?: string | null,
): string | null {
if (!file) return null
// An unrecognised kind still renders: a filename with no home
// directory is a bug, but a broken <img> says so louder than a
// silently absent one.
return (BY_KIND[kind ?? ''] ?? blockMedia)(file)
}
/* Initials for a photo that is missing or fails to load. Same
two-word rule PeopleTiles uses. */
export function initials(name = ''): string {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] || '')
.join('')
.toUpperCase()
}

57
src/lib/roles.ts Normal file
View file

@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════════
ROLES — src/lib/roles.ts
The same ladder as server/src/auth.js, and it has to stay the
same ladder. This copy exists to decide what to draw; the
server's copy decides what's allowed. If they ever disagree the
worst case is a button that 403s, which is the right way round
for them to fail.
Components should ask canDelete(user), not
user.role === "admin". The second form is what silently locked
superadmins out of saving when the third role went in: an
equality check against a ladder is a bug waiting for the next
role to be added, and there's now a fourth.
═══════════════════════════════════════════════════════════════ */
export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const;
export type Role = (typeof ROLES)[number];
export const ROLE_RANK: Record<Role, number> = {
viewer: 1,
editor: 2,
admin: 3,
superadmin: 4,
};
export const ROLE_LABELS: Record<Role, string> = {
viewer: "Viewer",
editor: "Editor",
admin: "Admin",
superadmin: "Superadmin",
};
/* Each line is what that role adds to the one above it in the
list. Read top to bottom, they describe the whole ladder. */
export const ROLE_NOTES: Record<Role, string> = {
viewer: "Can read everything in the CMS and change nothing.",
editor: "Can create and update records. Can't delete anything.",
admin: "Can delete records, including feedback.",
superadmin: "Can manage accounts, roles and sessions.",
};
type MaybeUser = { role?: string | null } | null | undefined;
/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */
export function atLeast(user: MaybeUser, role: Role): boolean {
const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0;
return have >= ROLE_RANK[role];
}
/* Named for the capability rather than the rank, so call sites
read as intent and a future reshuffle of the ladder is one edit
here rather than a search for every comparison. */
export const canWrite = (user: MaybeUser) => atLeast(user, "editor");
export const canDelete = (user: MaybeUser) => atLeast(user, "admin");
export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin");

390
src/lib/timeline.ts Normal file
View file

@ -0,0 +1,390 @@
/**
* Timeline types + grouping.
*
* This is the contract between the future `GET /api/history` route and the
* history page.
*
* ── Reference, don't duplicate ─────────────────────────────────────────
* An entry is a *pointer* to a record plus an optional narrative override.
* When the admin panel's "add to timeline" button fires on an event, it
* writes a row holding the event's id and nothing else; title, logo and
* date are read back from `events` at query time. Editing the event
* therefore edits the timeline, and there is no second copy to drift.
*
* Hand-authored entries — "bylaws rewritten", "the gathering becomes
* annual" — carry no ref and supply their own title and blurb. An entry
* may also do both: reference an event but override its title, for when
* the timeline wants to say something the event card doesn't.
*
* ── What the server resolves, and what it doesn't ──────────────────────
* The server resolves *data*: title, date, logo filename, the members of
* a referenced team. It does not resolve *routes* or *asset paths* —
* those are presentation, and live in `timelineRefs.ts` so React Router
* and the public/ layout stay the frontend's business.
*/
export type DatePrecision = 'year' | 'month' | 'day'
/** What an entry is about. Drives the marker and the body layout. */
export type TimelineKind =
| 'milestone' // free-standing narrative, no record behind it
| 'event'
| 'organization'
| 'award'
| 'people' // a team forming, someone joining one
/** Tables an entry can point at. Mirrors the polymorphic owner_kind
* pattern already used by content_blocks and links. */
export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team'
export type TimelineRef = {
kind: RefKind
/** The row's TEXT primary key — an event id, org slug, team slug. */
id: string
}
/** Filename plus the table it came from; the directory is derived
* frontend-side, because asset layout is not database business. */
export type TimelineLogo = {
file: string
kind: RefKind
}
/** A person as they appear in a 'people' entry. Resolved server-side,
* whether the entry named a team or listed people directly. */
export type PersonRef = {
id: string
name: string
/** people.photo — filename only. */
photo?: string
/** Their affiliation title at the time, if it's worth printing. */
title?: string
}
export type TeamRef = {
id: string
name: string
orgId?: string
orgName?: string
logo?: string
}
export type TimelineItem = {
/** The timeline row's own id, not the referenced record's. */
id: string
/** "2014" | "2014-06" | "2014-06-12" */
date: string
/** How much of `date` is trustworthy. Authoritative — a backfilled row
* may hold a full date while only the year is actually known. */
precision: DatePrecision
kind: TimelineKind
/** Falls back to the referenced record's own name when the row has no
* title of its own. Resolved server-side. */
title: string
blurb?: string
/** Secondary line: host org, region, venue, recipient. */
meta?: string
featured?: boolean
/** The record this points at. Absent for free-standing milestones. */
ref?: TimelineRef
/** Explicit link override. Absent → derived from `ref`. Every kind can
* carry one; event/organization/award fall back to their own page. */
href?: string
logo?: TimelineLogo
/** kind === 'people': who the entry is about. Populated from the named
* team's current members, or from an explicit person list. */
people?: PersonRef[]
/** Set when the entry named a team rather than loose people. */
team?: TeamRef
}
export type DecadeMeta = {
/** 2010, 2020, … */
decade: number
title: string
tagline: string
blurb?: string
/** Renders the ghosted treatment and the "before NGU" marker. */
preProgram?: boolean
}
export type GroupedMonth = {
month: number
label: string
items: TimelineItem[]
}
export type GroupedYear = {
year: number
featured: boolean
count: number
featuredItems: TimelineItem[]
/** Year-precision items — known to be this year, month unknown. */
undated: TimelineItem[]
months: GroupedMonth[]
}
export type GroupedDecade = DecadeMeta & {
years: GroupedYear[]
count: number
}
export type SortDirection = 'desc' | 'asc'
export const MONTH_LABELS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export function decadeOf(year: number): number {
return Math.floor(year / 10) * 10
}
export function decadeLabel(decade: number): string {
return `${decade}s`
}
// ── dates ────────────────────────────────────────────────────────────
type DateParts = { year: number; month: number | null; day: number | null }
function parseDate(item: TimelineItem): DateParts {
const [y, m, d] = item.date.split('-')
const year = Number(y)
if (!Number.isFinite(year)) {
throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`)
}
if (item.precision === 'year') return { year, month: null, day: null }
const month = m ? Number(m) : null
if (item.precision === 'month') return { year, month, day: null }
return { year, month, day: d ? Number(d) : null }
}
const pad = (n: number) => String(n).padStart(2, '0')
/**
* Start of the item's date window, as a sortable YYYY-MM-DD.
*
* A year-precision item resolves to 1 January, a month-precision one to
* the 1st. That makes "is this still upcoming?" answerable for imprecise
* dates in the one way that can't surprise anyone: an entry stops being
* upcoming as soon as any part of its window has passed. A row dated
* only "2027" is upcoming through the end of 2026 and no longer is on
* 1 January 2027, even though its real date may be months away.
*/
export function windowStart(item: TimelineItem): string {
const { year, month, day } = parseDate(item)
return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}`
}
export function todayISO(now: Date = new Date()): string {
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/**
* Split upcoming from recorded, off the wall clock rather than a flag.
* Nothing needs flipping when a date passes.
*/
export function partitionByDate(
items: TimelineItem[],
now: Date = new Date(),
): { upcoming: TimelineItem[]; past: TimelineItem[] } {
const today = todayISO(now)
const upcoming: TimelineItem[] = []
const past: TimelineItem[] = []
for (const item of items) {
if (windowStart(item) > today) upcoming.push(item)
else past.push(item)
}
return { upcoming, past }
}
// ── grouping ─────────────────────────────────────────────────────────
function byDay(dir: SortDirection) {
return (a: TimelineItem, b: TimelineItem) => {
const da = parseDate(a).day
const db = parseDate(b).day
if (da == null && db == null) return a.title.localeCompare(b.title)
if (da == null) return 1
if (db == null) return -1
return dir === 'desc' ? db - da : da - db
}
}
function byFeaturedThenDay(dir: SortDirection) {
const day = byDay(dir)
return (a: TimelineItem, b: TimelineItem) => {
if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1
return day(a, b)
}
}
export type GroupOptions = {
direction?: SortDirection
/**
* Decades ending before this year get the pre-program treatment even
* if the decade row doesn't say so. Lets the gap survive missing
* metadata.
*/
programStartYear?: number
/**
* Repeat featured items inside their month node as well as in the
* featured block. Off by default — in a sparse year it just prints the
* same line twice. A month left with nothing but featured items drops
* out entirely.
*/
featuredInMonths?: boolean
}
/** Bucket a flat item list into years. Shared by the main rail and the
* upcoming block above it. */
export function groupYears(
items: TimelineItem[],
options: GroupOptions = {},
): GroupedYear[] {
const direction = options.direction ?? 'desc'
const featuredInMonths = options.featuredInMonths ?? false
const sign = direction === 'desc' ? -1 : 1
const yearBuckets = new Map<number, TimelineItem[]>()
for (const item of items) {
const { year } = parseDate(item)
const bucket = yearBuckets.get(year)
if (bucket) bucket.push(item)
else yearBuckets.set(year, [item])
}
const years: GroupedYear[] = []
for (const [year, yearItems] of yearBuckets) {
const featuredItems: TimelineItem[] = []
const undated: TimelineItem[] = []
const monthMap = new Map<number, TimelineItem[]>()
for (const item of yearItems) {
if (item.featured) {
featuredItems.push(item)
if (!featuredInMonths) continue
}
const { month } = parseDate(item)
if (month == null) {
undated.push(item)
continue
}
const bucket = monthMap.get(month)
if (bucket) bucket.push(item)
else monthMap.set(month, [item])
}
const months: GroupedMonth[] = [...monthMap.entries()]
.sort((a, b) => sign * (a[0] - b[0]))
.map(([month, monthItems]) => ({
month,
label: MONTH_LABELS[month - 1] ?? `Month ${month}`,
items: monthItems.sort(byFeaturedThenDay(direction)),
}))
featuredItems.sort(byDay(direction))
undated.sort((a, b) => a.title.localeCompare(b.title))
years.push({
year,
featured: featuredItems.length > 0,
count: yearItems.length,
featuredItems,
undated,
months,
})
}
return years.sort((a, b) => sign * (a.year - b.year))
}
export function groupTimeline(
items: TimelineItem[],
decades: DecadeMeta[],
options: GroupOptions = {},
): GroupedDecade[] {
const direction = options.direction ?? 'desc'
const sign = direction === 'desc' ? -1 : 1
const metaByDecade = new Map(decades.map((d) => [d.decade, d]))
const decadeBuckets = new Map<number, GroupedYear[]>()
for (const year of groupYears(items, options)) {
const dec = decadeOf(year.year)
const bucket = decadeBuckets.get(dec)
if (bucket) bucket.push(year)
else decadeBuckets.set(dec, [year])
}
// Include decades that have metadata but no items yet, so an authored
// "before NGU" decade still renders its marker.
for (const meta of decades) {
if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, [])
}
return [...decadeBuckets.entries()]
.sort((a, b) => sign * (a[0] - b[0]))
.map(([decade, years]) => {
const meta = metaByDecade.get(decade)
const inferredPreProgram =
options.programStartYear != null && decade + 9 < options.programStartYear
return {
decade,
title: meta?.title ?? decadeLabel(decade),
tagline: meta?.tagline ?? '',
blurb: meta?.blurb,
preProgram: meta?.preProgram ?? inferredPreProgram,
years: years.sort((a, b) => sign * (a.year - b.year)),
count: years.reduce((sum, y) => sum + y.count, 0),
}
})
}
/**
* Insert empty year nodes between the first and last year that actually
* has data, so sparse decades read as gaps in the record rather than as
* a shorter decade. Does not pad beyond the data.
*/
export function withGapYears(
years: GroupedYear[],
direction: SortDirection = 'desc',
): GroupedYear[] {
if (years.length < 2) return years
const present = new Map(years.map((y) => [y.year, y]))
const all = years.map((y) => y.year)
const min = Math.min(...all)
const max = Math.max(...all)
const filled: GroupedYear[] = []
for (let year = min; year <= max; year += 1) {
filled.push(
present.get(year) ?? {
year,
featured: false,
count: 0,
featuredItems: [],
undated: [],
months: [],
},
)
}
return direction === 'desc' ? filled.reverse() : filled
}
/** Years that should start expanded: the most recent year with featured items. */
export function defaultOpenYears(decades: GroupedDecade[]): number[] {
for (const decade of decades) {
if (decade.preProgram) continue
const hit = decade.years.find((y) => y.featured)
if (hit) return [hit.year]
}
return []
}

75
src/lib/timelineRefs.ts Normal file
View file

@ -0,0 +1,75 @@
/* ═══════════════════════════════════════════════════════════════
TIMELINE REFS
Turns a timeline item into a destination and an image. Both were
answered locally here before, which is how the timeline came to
link events at /event/:id while the router only knew about
/retreats/:id — two files holding the same opinion, one of them
wrong, neither aware of the other.
Now this file knows about timeline items and nothing else. Where
a record lives is hrefs.ts; where an image lives is media.ts.
── The shape this reads, from history.js ──
item.href explicit link_url override, may be off-site
item.ref { kind, id, orgKind? } — orgKind only on
organizations, because only they have it
item.logo { file, kind } — v_timeline COALESCEs the
filename across five tables, so the kind is
what says which directory it came from
item.team { id, name, orgId? } on a team ref
item.people[] { id, name, photo?, title? }
Every one of those is optional. An entry is a standalone
milestone until proven otherwise, and the two accessors below
return null rather than assuming a shape that isn't there —
which is the other half of the /organizations/undefined bug:
reaching into a ref that wasn't sent yields undefined, and
undefined interpolates into a path perfectly happily.
═══════════════════════════════════════════════════════════════ */
import { refHref } from './hrefs.ts'
import { logoForKind, personPhoto } from './media.ts'
import type { TimelineItem } from './timeline.ts'
/**
* Where this entry points, or null if nowhere.
*
* An explicit link_url wins: it's the editor deliberately
* overriding the record's own page, usually to send someone to an
* external write-up. TimelineEntry checks for a scheme and renders
* an <a> instead of a <Link>, so this returns it unchanged.
*
* Otherwise the reference decides, and refHref returns null for a
* kind with no page yet ('person', until /people/:id exists) as
* well as for a ref that didn't resolve. Null means the entry
* renders as a plain <div> — right for a milestone, and right for
* a reference that's missing an id, which used to render as a link
* to a 404.
*/
export function hrefFor(item: TimelineItem): string | null {
if (item.href) return item.href
const ref = item.ref
if (!ref) return null
return refHref(ref.kind, ref.id, ref.orgKind)
}
/**
* The entry's image, resolved against the directory for whatever
* kind of record the filename came from.
*/
export function logoSrc(item: TimelineItem): string | null {
if (!item.logo) return null
return logoForKind(item.logo.kind, item.logo.file)
}
/**
* A roster member's photo. Same rule as everywhere else — the API
* sends a bare filename.
*/
export function photoSrc(photo?: string | null): string | null {
return personPhoto(photo)
}

243
src/lib/useContent.ts Normal file
View file

@ -0,0 +1,243 @@
/**
* The four detail endpoints, typed.
*
* Adding a fifth is a type and a one-line hook — useRecord owns
* the fetching, the caching and the 404.
*
* An id that is missing — or the literal string "undefined", which
* is what a template literal makes of a missing route param — never
* reaches the network. detailPath returns null and the hook reports
* notFound, which is what the visitor should see anyway, and
* hrefs.ts has already logged the component that produced it.
*/
import { detailPath } from './hrefs.ts'
import { useRecord, type Resource } from './useRecord.ts'
import type { EventType } from './eventTypes.ts'
/* ── Shared shapes ───────────────────────────────────────────── */
/** A content_blocks row with its `items` child, as shape.js sends it. */
export type ContentBlock = {
id?: number | string
slot?: 'card' | 'body'
type:
| 'heading'
| 'subheading'
| 'paragraph'
| 'list'
| 'links'
| 'quote'
| 'image'
| 'divider'
text?: string | null
media?: string | null
href?: string | null
items?: Array<{ text: string; detail?: string | null; url?: string | null }>
}
export type Link = {
kind?: string
platform?: string | null
label: string
url: string
is_primary?: boolean
}
export type OrgRef = { id: string; name: string; kind?: string | null }
/* One host of an event. `kind` says which table the id is in;
`org_kind` is the region/chapter/partner split that decides an
organization's route, and is null for a person. The triple is
exactly what refHref() in hrefs.ts takes. */
export type EventHost = {
kind: 'organization' | 'person'
id: string
name: string
org_kind?: string | null
}
/* ── Events ──────────────────────────────────────────────────── */
export type EventPerson = {
person_id: string
display_name: string
pronouns?: string | null
tagline?: string | null
photo?: string | null
role?: string | null
title?: string | null
}
export type EventAward = {
award: { id: string; name: string; logo?: string | null }
person: { id: string; name: string; photo?: string | null }
awarded_on?: string | null
citation?: string | null
}
export type EventRecord = {
id: string
/** Which band of the Retreats page this belongs to. */
section_id: string
/** What kind of gathering it is. Orthogonal to section_id. */
event_type: EventType
title: string
theme?: string | null
tagline?: string | null
starts_on?: string | null
ends_on?: string | null
date_label?: string | null
status: 'upcoming' | 'past' | 'cancelled'
location_label?: string | null
locality?: string | null
state_code?: string | null
country?: string | null
is_online: boolean
org_logo?: string | null
event_logo?: string | null
color?: string | null
gradient?: string | null
/* In billing order. The first is the one `color` and `org_logo`
fell back to when the event set neither. */
hosts: EventHost[]
description: string[]
links: Link[]
instagram?: Link | null
blocks: ContentBlock[]
people: EventPerson[]
awards: EventAward[]
}
export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event')
/* ── Organizations ───────────────────────────────────────────── */
export type Leader = {
person_id: string
display_name: string
pronouns?: string | null
title?: string | null
role?: string | null
is_owner: boolean
photo?: string | null
public_email?: string | null
team_id?: string | null
team_name?: string | null
}
export type OrgTeam = {
id: string
name: string
tagline?: string | null
color?: string | null
logo?: string | null
}
export type OrgAward = {
id: string
name: string
description?: string | null
logo?: string | null
recipient_count: number
}
export type OrgEvent = {
id: string
title: string
event_type: EventType
date_label?: string | null
status: string
location_label?: string | null
event_logo?: string | null
color?: string | null
}
export type OrganizationRecord = {
id: string
kind: 'national' | 'region' | 'chapter' | 'partner'
name: string
short_name?: string | null
tagline?: string | null
color?: string | null
logo?: string | null
venue?: string | null
address?: string | null
locality?: string | null
state_code?: string | null
country?: string | null
location_label?: string | null
is_online: boolean
description: string[]
blocks: ContentBlock[]
links: Link[]
socials: Link[]
website?: Link | null
email?: Link | null
instagram?: Link | null
/** Shape depends on `kind`; empty object for national and partner. */
details: {
scope?: string | null
map_note?: string | null
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
region_id?: string | null
region_name?: string | null
region_color?: string | null
meets?: string | null
started?: string | null
}
leadership: Leader[]
teams: OrgTeam[]
awards: OrgAward[]
events: OrgEvent[]
}
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
/* ── Teams ───────────────────────────────────────────────────── */
/** No members here on purpose — PeopleTiles fetches
* /teams/:id/people itself. See the note in content.js. */
export type TeamRecord = {
id: string
name: string
tagline?: string | null
color?: string | null
logo?: string | null
org: OrgRef
description: string[]
links: Link[]
socials: Link[]
instagram?: Link | null
blocks: ContentBlock[]
}
export const useTeam = (id?: string): Resource<TeamRecord> =>
useRecord<TeamRecord>(detailPath('/teams', id), 'team')
/* ── Awards ──────────────────────────────────────────────────── */
export type Recipient = {
id: string
name: string
photo?: string | null
tagline?: string | null
awarded_on?: string | null
citation?: string | null
event: { id: string; title: string } | null
}
export type AwardRecord = {
id: string
name: string
description?: string | null
logo?: string | null
org: OrgRef | null
recipients: Recipient[]
}
export const useAward = (id?: string): Resource<AwardRecord> =>
useRecord<AwardRecord>(detailPath('/awards', id), 'award')

87
src/lib/useHistory.ts Normal file
View file

@ -0,0 +1,87 @@
/**
* Loads the history timeline from `GET /api/history`.
*
* Goes through the shared client, so the request is deduped and cached
* for 60s like every other read. Two consequences worth knowing:
*
* · `reload` has to invalidate before it refetches. Without that, the
* retry button inside the TTL would hand back the same settled
* promise and look like it did nothing. A *failed* request is
* already evicted by the client, so this matters for the refresh
* case rather than the error case.
*
* · there's no AbortController. `get` shares one promise between
* callers, so aborting on unmount would cancel someone else's
* request. The `live` flag drops the result instead.
*
* No `fallback` on purpose. Handing this the mock data would render a
* plausible-looking history with no indication the server is down, and
* the wrong history is worse than a visible error — the same reason
* `fallback: EMPTY` came out elsewhere.
*
* `undated` is the number of published entries the API left out because
* nothing gave them a date. Not rendered publicly — a visitor can't act
* on it — but returned so it's reachable if you want a warning in the
* admin later.
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
import type { TimelineItem } from './timeline'
const PATH = '/history'
type HistoryResponse = {
items?: TimelineItem[]
undated?: number
}
type State = {
items: TimelineItem[]
undated: number
loading: boolean
error: string | null
reload: () => void
}
export function useHistory(): State {
const [items, setItems] = useState<TimelineItem[]>([])
const [undated, setUndated] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const reload = useCallback(() => {
invalidate(PATH)
setAttempt((n) => n + 1)
}, [])
useEffect(() => {
let live = true
async function load() {
setLoading(true)
setError(null)
try {
const data: HistoryResponse = await get(PATH)
if (!live) return
setItems(Array.isArray(data?.items) ? data.items : [])
setUndated(Number(data?.undated) || 0)
} catch (err) {
if (!live) return
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
)
} finally {
if (live) setLoading(false)
}
}
load()
return () => {
live = false
}
}, [attempt])
return { items, undated, loading, error, reload }
}

127
src/lib/useRecord.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* useRecord — one record from one endpoint, on the useHistory
* pattern.
*
* Named for what it returns, and deliberately not useResource:
* src/lib/useResource.js is a different hook — it hands back the
* whole response body and takes an options object — and a .ts file
* of the same name would sit one extension away from it. An import
* whose target goes missing then resolves to the other file without
* a word, and every detail page renders the wrapper object instead
* of the record. That is exactly how this file came to exist.
*
* Every detail route in content.js answers the same shape: 200 with
* a single top-level key, or 404 with `{ error }`. So the hook takes
* the path and the key, and the four callers in useContent.ts are
* one line each rather than four copies of this file.
*
* `key` is a string rather than a selector function on purpose. A
* selector passed inline would be a new identity every render, and
* putting it in the effect's deps would refetch forever; leaving it
* out would silently use a stale closure. A string has neither
* problem.
*
* Carried over from useHistory, and worth restating:
*
* · `reload` invalidates before it refetches. Without that, the
* retry button inside the 60s TTL hands back the same settled
* promise and looks like it did nothing. A *failed* request is
* already evicted by api.js, so this matters for the refresh
* case rather than the error case.
*
* · no AbortController. `get` shares one promise between callers,
* so aborting on unmount would cancel someone else's request.
* The `live` flag drops the result instead.
*
* · no `fallback`. Rendering plausible-looking content with no
* sign the server is down is worse than a visible error.
*
* `notFound` is separated from `error` because they are different
* pages: a 404 is a slug that doesn't exist and retrying won't help,
* anything else is worth a Try again button.
*
* A null `path` means there is nothing to ask for, and the hook
* reports notFound rather than loading forever. Callers build the
* path with detailPath() from hrefs.ts, which returns null for an
* id that could never be real — "undefined" chief among them.
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
export type Resource<T> = {
data: T | null
loading: boolean
error: string | null
notFound: boolean
reload: () => void
}
export function useRecord<T>(path: string | null, key: string): Resource<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
const [attempt, setAttempt] = useState(0)
const reload = useCallback(() => {
if (path) invalidate(path)
setAttempt((n) => n + 1)
}, [path])
useEffect(() => {
// Nothing to ask for. Distinguished from "not asked yet" by the
// caller: useContent passes null only when the id is unusable,
// and an unusable id is a 404 as far as the visitor is
// concerned.
if (!path) {
setData(null)
setError(null)
setNotFound(true)
setLoading(false)
return undefined
}
let live = true
async function load() {
setLoading(true)
setError(null)
setNotFound(false)
try {
const body = await get(path as string)
if (!live) return
// A 200 with the key absent is a server-side shaping bug,
// not an empty record. Say so rather than rendering a page
// full of blanks.
const record = body?.[key]
if (record === undefined) {
setError(`The server sent no "${key}".`)
setData(null)
return
}
setData(record as T)
} catch (err) {
if (!live) return
if (err instanceof ApiError && err.status === 404) {
setNotFound(true)
setData(null)
return
}
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
)
setData(null)
} finally {
if (live) setLoading(false)
}
}
load()
return () => {
live = false
}
}, [path, key, attempt])
return { data, loading, error, notFound, reload }
}

10
src/lib/version.ts Normal file
View file

@ -0,0 +1,10 @@
/* ═══════════════════════════════════════════════════════════════
SITE VERSION — src/lib/version.ts
One string, bumped by hand when the working directory name
changes. The admin footer is the only thing reading it today;
keep it here rather than in a component so the panel, an about
box or a build banner can read the same value later.
═══════════════════════════════════════════════════════════════ */
export const SITE_VERSION = "NGU-Web.v1.5-history";

View file

@ -7,6 +7,7 @@ export const PAGE_LINKS = [
{ label: "Community", path: "/community" }, { label: "Community", path: "/community" },
{ label: "Leadership", path: "/leadership" }, { label: "Leadership", path: "/leadership" },
{ label: "Resources", path: "/resources" }, { label: "Resources", path: "/resources" },
{ label: "History", path: "/history" },
]; ];
// Sections belong to a page, keyed by that page's path. // Sections belong to a page, keyed by that page's path.
@ -38,6 +39,9 @@ export const PAGE_SECTIONS = {
{ label: "Branding & Marketing", hash: "#branding" }, { label: "Branding & Marketing", hash: "#branding" },
{ label: "Partner Resources", hash: "#partner" }, { label: "Partner Resources", hash: "#partner" },
], ],
"/history": [
{ label: "Timeline", hash: "#timeline" },
],
}; };
// Right-side actions. `variant` picks the styling, not the destination. // Right-side actions. `variant` picks the styling, not the destination.

178
src/pages/AwardDetail.tsx Normal file
View file

@ -0,0 +1,178 @@
/* ═══════════════════════════════════════════════════════════════
AWARD DETAIL — /awards/:id
The thinnest of the four, by design. `awards` has no links and no
content blocks — 'award' isn't in the owner_kind CHECK on either
polymorphic table, and widening it means rebuilding two STRICT
tables. `description` is the prose.
So the recipients are the page. They're rendered as a list rather
than as PeopleTiles because each one carries a citation, and a
citation is the point of an award — it doesn't belong folded
behind a chevron.
Note also that `awards` has no is_published column: every row
here is live the moment it's created. Worth an ADD COLUMN before
this ships.
═══════════════════════════════════════════════════════════════ */
import { Link, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import { useAward, type Recipient } from '../lib/useContent.ts'
import { eventHref, orgHref, personHref } from '../lib/hrefs.ts'
import { awardLogo, initials, personPhoto } from '../lib/media.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function AwardDetail() {
const { id } = useParams()
const { data: award, loading, error, notFound, reload } = useAward(id)
if (!award) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="award"
backTo="/community"
backLabel="Community"
/>
)
}
const accent = TEAL
const logo = awardLogo(award.logo)
return (
<PageShell
title={award.name}
intro={award.description ?? undefined}
sections={[
{
id: 'recipients',
title: 'Recipients',
blurb:
award.recipients.length === 0
? 'Nobody has received this yet.'
: undefined,
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
{logo && (
<img src={logo} alt="" loading="lazy" className="h-12 w-12 object-contain" />
)}
{award.org && (
<span style={{ color: BODY }}>
Given by{' '}
<Link
to={orgHref(award.org.id, award.org.kind)}
className="font-medium hover:underline"
style={{ color: accent }}
>
{award.org.name}
</Link>
</span>
)}
<span style={{ color: BODY }}>
{award.recipients.length}{' '}
{award.recipients.length === 1 ? 'recipient' : 'recipients'}
</span>
</div>
<ol className="space-y-8">
{award.recipients.map((recipient, index) => (
<li key={`${recipient.id}:${recipient.awarded_on ?? index}`}>
<RecipientRow recipient={recipient} accent={accent} />
</li>
))}
</ol>
</div>
),
},
]}
/>
)
}
function RecipientRow({ recipient, accent }: { recipient: Recipient; accent: string }) {
const photo = personPhoto(recipient.photo)
return (
<div className="flex gap-5">
{photo ? (
<img
src={photo}
alt=""
loading="lazy"
decoding="async"
className="h-16 w-16 shrink-0 rounded-full object-cover"
/>
) : (
<span
aria-hidden="true"
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ background: accent }}
>
{initials(recipient.name)}
</span>
)}
<div className="min-w-0">
<p className="text-lg font-semibold">
<Link
to={personHref(recipient.id)}
className="hover:underline"
style={{ color: accent }}
>
{recipient.name}
</Link>
</p>
<p className="text-sm" style={{ color: BODY }}>
{[
year(recipient.awarded_on),
recipient.tagline,
]
.filter(Boolean)
.join(' · ')}
{recipient.event && (
<>
{(recipient.awarded_on || recipient.tagline) && ' · '}
<Link
to={eventHref(recipient.event.id)}
className="hover:underline"
style={{ color: accent }}
>
{recipient.event.title}
</Link>
</>
)}
</p>
{recipient.citation && (
<p className="mt-2 max-w-2xl leading-relaxed" style={{ color: BODY }}>
{recipient.citation}
</p>
)}
</div>
</div>
)
}
/* awarded_on may be a partial date, so this takes the leading year
rather than parsing. A full date would be false precision on a
row backfilled from a programme booklet. */
function year(date?: string | null): string | null {
if (!date) return null
const match = /^(\d{4})/.exec(date)
return match ? match[1] : date
}

352
src/pages/EventDetail.tsx Normal file
View file

@ -0,0 +1,352 @@
/* ═══════════════════════════════════════════════════════════════
EVENT DETAIL — /retreats/:id
Reached from the cards on Retreats.tsx. The API has already
resolved the fallbacks — `color` is the event's own or its first
host's, `status` is derived from the dates when nobody set it —
so nothing here reimplements those rules.
Hosts arrive as a list in billing order, each one either an
organization or a person. refHref takes the kind and works out
the route, which is why this file doesn't branch on it.
People are grouped by their billing role rather than listed flat.
A retreat with four speakers and eleven volunteers reads as two
different things, and v_event_people already sorts within a role
by sort_order.
═══════════════════════════════════════════════════════════════ */
import { Link, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
import {
useEvent,
type EventHost,
type EventPerson,
type EventRecord,
} from '../lib/useContent.ts'
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
import { personPhoto } from '../lib/media.ts'
import { eventTypeLabel } from '../lib/eventTypes.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
/* Billing order. A role missing from here still renders, at the
end, under its own name — better than a speaker vanishing
because somebody added a role to the CHECK and not to this list. */
const ROLE_ORDER = [
'speaker',
'leader',
'facilitator',
'host',
'musician',
'volunteer',
'attendee',
] as const
const ROLE_LABEL: Record<string, string> = {
speaker: 'Speakers',
leader: 'Leaders',
facilitator: 'Facilitators',
host: 'Hosts',
musician: 'Music',
volunteer: 'Volunteers',
attendee: 'Also there',
}
const STATUS_LABEL: Record<string, string> = {
upcoming: 'Upcoming',
past: 'Past',
cancelled: 'Cancelled',
}
export default function EventDetail() {
const { id } = useParams()
const { data: event, loading, error, notFound, reload } = useEvent(id)
if (!event) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="retreat"
backTo="/retreats"
backLabel="All retreats"
/>
)
}
const accent = event.color || TEAL
const groups = peopleGroups(event.people, accent)
const sections = [
{
id: 'about',
title: event.theme || 'About',
blurb: event.theme ? event.tagline ?? undefined : undefined,
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts event={event} accent={accent} />
{event.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={event.blocks} accent={accent} />
</div>
{event.links.length > 0 && (
<div className="flex flex-wrap gap-3">
{event.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
</div>
)}
</div>
),
},
]
if (groups.length > 0) {
sections.push({
id: 'people',
title: 'Who’s there',
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="lg" groups={groups} accent={accent} />
</div>
),
})
}
if (event.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Presented here',
blurb: 'Awards given at this gathering.',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-6">
{event.awards.map((entry, index) => (
<div
key={`${entry.award.id}:${entry.person.id}:${index}`}
className="flex gap-4 border-l-2 pl-5"
style={{ borderColor: accent }}
>
<div>
<p className="font-semibold" style={{ color: accent }}>
<Link to={awardHref(entry.award.id)} className="hover:underline">
{entry.award.name}
</Link>
</p>
<p style={{ color: BODY }}>
<Link to={personHref(entry.person.id)} className="hover:underline">
{entry.person.name}
</Link>
</p>
{entry.citation && (
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
{entry.citation}
</p>
)}
</div>
</div>
))}
</div>
),
})
}
return (
<PageShell
title={event.title}
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
sections={sections}
/>
)
}
/* ── The strip of facts under the heading ────────────────────── */
function Facts({ event, accent }: { event: EventRecord; accent: string }) {
const where =
event.location_label ||
[event.locality, event.state_code].filter(Boolean).join(', ') ||
(event.is_online ? 'Online' : null)
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{
background: event.status === 'cancelled' ? '#b3261e' : accent,
color: '#ffffff',
}}
>
{STATUS_LABEL[event.status] ?? event.status}
</span>
{/* Outlined rather than filled: the status pill is the one
thing in this strip that should read as loud, and two
solid blocks side by side would compete. Shown
unconditionally — a card suppresses its own type badge in
a band of one kind, but here there is no band to make it
redundant. */}
{event.event_type && (
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{eventTypeLabel(event.event_type)}
</span>
)}
{when && <span style={{ color: BODY }}>{when}</span>}
{where && <span style={{ color: BODY }}>{where}</span>}
{event.is_online && where !== 'Online' && (
<span style={{ color: BODY }}>Online too</span>
)}
{(event.hosts?.length ?? 0) > 0 && (
<span style={{ color: BODY }}>
Hosted by <HostList hosts={event.hosts} accent={accent} />
</span>
)}
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
All retreats
</Link>
</div>
)
}
/* One host reads as "Hosted by Northwest"; several read as a
sentence, so they're joined with commas and an "and" rather than
stacked. A host whose id didn't resolve to a route renders as
plain text — refHref returns null for that — because a dead link
is worse than a name. */
function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) {
return (
<>
{hosts.map((host, index) => {
const to = refHref(host.kind, host.id, host.org_kind)
return (
<span key={`${host.kind}:${host.id}`}>
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
{index > 0 && index === hosts.length - 1 && 'and '}
{to ? (
<Link
to={to}
className="font-medium hover:underline"
style={{ color: accent }}
>
{host.name}
</Link>
) : (
<span className="font-medium">{host.name}</span>
)}
</span>
)
})}
</>
)
}
/* date_label is what a card shows and is free text — "March/April
2026" is legitimate. This is only the fallback for an event that
has dates and no label. */
function dateRange(start?: string | null, end?: string | null): string | null {
if (!start) return null
const from = new Date(`${start}T00:00:00`)
if (Number.isNaN(from.getTime())) return start
const full: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
}
if (!end || end === start) return from.toLocaleDateString(undefined, full)
const to = new Date(`${end}T00:00:00`)
if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full)
const sameYear = from.getFullYear() === to.getFullYear()
const sameMonth = sameYear && from.getMonth() === to.getMonth()
const left = from.toLocaleDateString(
undefined,
sameMonth
? { month: 'long', day: 'numeric' }
: sameYear
? { month: 'long', day: 'numeric' }
: full,
)
return `${left} – ${to.toLocaleDateString(undefined, full)}`
}
/* ── v_event_people → PeopleTiles groups ─────────────────────── */
function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] {
if (!people.length) return []
const byRole = new Map<string, EventPerson[]>()
for (const person of people) {
const role = person.role || 'attendee'
const list = byRole.get(role)
if (list) list.push(person)
else byRole.set(role, [person])
}
// Known roles in billing order, then anything the CHECK has
// gained since this file was written.
const roles = [
...ROLE_ORDER.filter((role) => byRole.has(role)),
...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)),
]
return roles.map((role) => ({
id: `role-${role}`,
label: ROLE_LABEL[role] ?? capitalize(role),
accent,
people: (byRole.get(role) ?? []).map((person) => ({
id: person.person_id,
name: person.display_name,
title: person.title,
tagline: person.tagline,
pronouns: person.pronouns,
// PeopleTiles resolves a bare filename itself; this is here
// so a hand-written entry elsewhere can't diverge.
photo: personPhoto(person.photo),
})),
}))
}
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)

84
src/pages/History.tsx Normal file
View file

@ -0,0 +1,84 @@
import PageShell from "../components/PageShell";
import HistoryTimeline from "./sections/history/HistoryTimeline";
import { HISTORY_DECADES } from "../data/historyDecades";
import { useHistory } from "../lib/useHistory";
/**
* Year the program starts. Decades ending before this render with the
* pre-program treatment, so the gap moves if the founding date is ever
* corrected — no hardcoded 2000 anywhere in the components.
*/
const PROGRAM_START_YEAR = 2000;
const ACCENT = "#138ba0";
const BACKGROUND = "#ffffff";
export default function History() {
const { items, loading, error, reload } = useHistory();
// Section renders `content` outside its own max-width wrapper, so the
// container lives here. The custom properties bind the timeline to
// this section's accent and background — --tl-surface must match
// `background` or the rail will show through the year dots.
const wrap = (children: React.ReactNode) => (
<div
className="max-w-6xl mx-auto px-6"
style={{ "--tl-accent": ACCENT, "--tl-surface": BACKGROUND } as React.CSSProperties}
>
{children}
</div>
);
let content: React.ReactNode;
if (loading) {
content = wrap(
<p className="py-12 text-[#4a6b72]" role="status">
Loading the timeline…
</p>,
);
} else if (error) {
// Say what failed and offer the one action that might fix it.
// A history page that silently renders nothing looks like an
// organization with no history.
content = wrap(
<div className="py-12">
<p className="text-[#b3261e]">Couldn’t load the timeline. {error}</p>
<button
type="button"
onClick={reload}
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
Try again
</button>
</div>,
);
} else {
content = wrap(
<HistoryTimeline
items={items}
decades={HISTORY_DECADES}
direction="desc"
programStartYear={PROGRAM_START_YEAR}
/>,
);
}
return (
<PageShell
title="History"
intro="A near-complete timeline of Next Generation of Unity (NGU), and other Unity Young Adult programs."
sections={[
{
id: "timeline",
title: "NGU Timeline",
blurb:
"Open a year to see what happened.",
accent: ACCENT,
background: BACKGROUND,
content,
},
]}
/>
);
}

View file

@ -0,0 +1,438 @@
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION DETAIL
/regions/:id /chapters/:id /partners/:id /organizations/:id
One component behind four routes, because organizations are one
table. The kind decides which extras render, not which file runs.
The URL kind is decoration — the slug is what identifies the
record — so a request for /chapters/great-lakes when that slug is
a region redirects to the canonical path rather than rendering a
correct page at a wrong address.
Leadership arrives flat with team_id and team_name on each row,
so grouping costs nothing. `teams` is fetched alongside anyway:
a team with no current public members would otherwise be
invisible here instead of listed, and its page unreachable.
═══════════════════════════════════════════════════════════════ */
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles from '../components/PeopleTiles.tsx'
import EventListCards from './sections/EventList-Cards.tsx'
import {
useOrganization,
type Leader,
type OrganizationRecord,
} from '../lib/useContent.ts'
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
import { orgLogo, personPhoto } from '../lib/media.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function OrganizationDetail() {
const { id } = useParams()
const { pathname } = useLocation()
const { data: org, loading, error, notFound, reload } = useOrganization(id)
if (!org) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="organization"
backTo="/community"
backLabel="Community"
/>
)
}
// /chapters/x when x is a region: same record, wrong address.
//
// useLocation, not window.location: the latter is outside the
// router's awareness — always "/" under HashRouter, and free to
// be stale mid-navigation, either of which turns this into a
// redirect loop rather than a one-shot correction.
//
// Guarded on org.id because a record with no id would redirect to
// /chapters/undefined, which is a worse page than the one we're
// already on. All four organization routes must be registered or
// this redirects somewhere nothing matches.
const canonical = org.id ? orgHref(org.id, org.kind) : null
if (canonical && decodeURIComponent(pathname) !== canonical) {
return <Navigate to={canonical} replace />
}
const accent = org.color || TEAL
const back = orgListHref(org.kind)
const sections: any[] = [
{
id: 'about',
title: 'About',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts org={org} accent={accent} back={back} />
{org.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={org.blocks} accent={accent} />
</div>
<Contact org={org} accent={accent} />
</div>
),
},
]
const teamBlocks = groupLeadership(org)
if (teamBlocks.length > 0) {
sections.push({
id: 'leadership',
title: 'Who runs it',
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-12">
{teamBlocks.map((block) => (
<div key={block.key}>
<div className="mb-4">
{block.teamId ? (
<h3 className="text-xl font-bold">
<Link
to={teamHref(block.teamId)}
className="hover:underline"
style={{ color: accent }}
>
{block.label}
</Link>
</h3>
) : (
block.label && (
<h3 className="text-xl font-bold" style={{ color: accent }}>
{block.label}
</h3>
)
)}
{block.tagline && (
<p className="mt-1 text-sm" style={{ color: BODY }}>
{block.tagline}
</p>
)}
</div>
{block.people.length > 0 ? (
<PeopleTiles
size="lg"
accent={block.accent || accent}
people={block.people.map((leader) => ({
id: leader.person_id,
name: leader.display_name,
title: leader.title,
pronouns: leader.pronouns,
public_email: leader.public_email,
photo: personPhoto(leader.photo),
is_owner: leader.is_owner,
}))}
/>
) : (
<p className="text-sm italic" style={{ color: BODY }}>
No members listed yet.
</p>
)}
</div>
))}
</div>
),
})
}
if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) {
sections.push({
id: 'chapters',
title: 'Chapters',
blurb: org.details.map_note ?? undefined,
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(org.details.chapters ?? []).map((chapter) => (
<Link
key={chapter.id}
to={orgHref(chapter.id, 'chapter')}
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: `${accent}40` }}
>
<Logo file={chapter.logo} name={chapter.name} accent={accent} />
<span>
<span className="block font-semibold" style={{ color: accent }}>
{chapter.name}
</span>
{chapter.location_label && (
<span className="block text-sm" style={{ color: BODY }}>
{chapter.location_label}
</span>
)}
</span>
</Link>
))}
</div>
),
})
}
if (org.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Awards',
blurb: `Given by ${org.short_name || org.name}.`,
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2">
{org.awards.map((award) => (
<Link
key={award.id}
to={`/awards/${award.id}`}
className="rounded-lg border bg-white p-5 transition-colors hover:bg-[#f6fbfc]"
style={{ borderColor: `${accent}40` }}
>
<span className="block font-semibold" style={{ color: accent }}>
{award.name}
</span>
{award.description && (
<span className="mt-1 block text-sm" style={{ color: BODY }}>
{award.description}
</span>
)}
<span className="mt-3 block text-xs uppercase tracking-wide opacity-70" style={{ color: BODY }}>
{award.recipient_count === 0
? 'No recipients yet'
: `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`}
</span>
</Link>
))}
</div>
),
})
}
// EventList-Cards fetches and renders this itself — its own
// docstring offers `host` for exactly this case, and a card there
// already knows about gradients, logos, past-event collapsing and
// the carousel. A second grid here was the same component written
// worse.
//
// `org.events` is still what decides whether the section exists at
// all: an organization that has never hosted anything shouldn't
// get a heading followed by "events coming soon".
if (org.events.length > 0) {
sections.push({
id: 'events',
title: 'Gatherings',
accent,
background: '#ffffff',
content: (
<EventListCards
host={org.id}
view="grid"
accent={accent}
empty="· Nothing on the calendar right now ·"
/>
),
})
}
return <PageShell title={org.name} intro={org.tagline ?? undefined} sections={sections} />
}
/* ── Pieces ──────────────────────────────────────────────────── */
function Facts({
org,
accent,
back,
}: {
org: OrganizationRecord
accent: string
back: { to: string; label: string }
}) {
const where =
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
{where && <span style={{ color: BODY }}>{where}</span>}
{org.kind === 'region' && org.details.scope && (
<span style={{ color: BODY }}>{org.details.scope}</span>
)}
{org.kind === 'chapter' && org.details.region_id && (
<span style={{ color: BODY }}>
Part of{' '}
<Link
to={orgHref(org.details.region_id, 'region')}
className="font-medium hover:underline"
style={{ color: org.details.region_color || accent }}
>
{org.details.region_name}
</Link>
</span>
)}
{org.kind === 'chapter' && org.details.meets && (
<span style={{ color: BODY }}>Meets {org.details.meets}</span>
)}
{org.kind === 'chapter' && org.details.started && (
<span style={{ color: BODY }}>Since {org.details.started}</span>
)}
{org.is_online && <span style={{ color: BODY }}>Online</span>}
<Link to={back.to} className="ml-auto hover:underline" style={{ color: accent }}>
{back.label}
</Link>
</div>
)
}
function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
const hasAny =
org.links.length > 0 || org.socials.length > 0 || org.website || org.email
if (!hasAny) return null
return (
<div className="flex flex-wrap gap-3">
{org.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
{[org.website, org.email, ...org.socials]
.filter((link): link is NonNullable<typeof link> => Boolean(link))
.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: accent, color: accent }}
>
{link.label}
</a>
))}
</div>
)
}
function Logo({
file,
name,
accent,
}: {
file?: string | null
name: string
accent: string
}) {
const src = orgLogo(file)
if (!src) {
return (
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: accent }}
>
{name.slice(0, 2).toUpperCase()}
</span>
)
}
return <img src={src} alt="" loading="lazy" className="h-10 w-10 shrink-0 object-contain" />
}
/* ── Leadership → team blocks ────────────────────────────────── */
type TeamBlock = {
key: string
teamId: string | null
label: string | null
tagline?: string | null
accent?: string | null
people: Leader[]
}
/* Order comes from `teams` (the admin's sort_order), not from the
order people happen to appear in. Three cases to cover:
affiliations with no team at all, teams with nobody in them, and
people filed under a team that is no longer published. */
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
const byTeam = new Map<string, Leader[]>()
const loose: Leader[] = []
for (const leader of org.leadership) {
if (!leader.team_id) {
loose.push(leader)
continue
}
const list = byTeam.get(leader.team_id)
if (list) list.push(leader)
else byTeam.set(leader.team_id, [leader])
}
const blocks: TeamBlock[] = []
// People who hold a role in the organization without sitting on a
// team. Usually the leads. No heading — they are the page.
if (loose.length > 0) {
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
}
for (const team of org.teams) {
blocks.push({
key: team.id,
teamId: team.id,
label: team.name,
tagline: team.tagline,
accent: team.color,
people: byTeam.get(team.id) ?? [],
})
byTeam.delete(team.id)
}
// Whatever is left is filed under an unpublished team. Its page
// isn't reachable, so the heading is plain text — but the people
// are real and shouldn't silently vanish from the org.
for (const [teamId, people] of byTeam) {
blocks.push({
key: teamId,
teamId: null,
label: people[0]?.team_name ?? null,
people,
})
}
return blocks
}

View file

@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
different filter. The page declares them and does nothing else — different filter. The page declares them and does nothing else —
each section fetches its own slice. each section fetches its own slice.
Two filters per band now, and they answer different questions:
section whose gathering it is — the scope. event_sections
holds six of these; this page draws three.
type what kind of gathering it is. Pinned to "retreat"
everywhere on this page, which is what the page is
for and what lets "Partner" read as a heading rather
than a catch-all.
Because every band is pinned to one type, EventListCards finds a
single kind in each and draws no type chips. Drop the `type` from
a band and the chips appear on their own.
What this page deliberately does not show: local, international
and other scopes, and every non-retreat type. Those are reachable
from their host's page and by URL, and get a band here when
there's enough of them to fill one — see the block at the bottom.
To reorder the page, move a line. To add a different kind of To reorder the page, move a line. To add a different kind of
section, add an entry with another Component. section, add an entry with another Component.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle }; const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
/* Pinned on every band. Named rather than repeated so turning this
page into "everything, filtered" later is one deletion. */
const RETREATS = { type: "retreat" };
const SECTIONS = [ const SECTIONS = [
{ {
id: "national", id: "national",
@ -23,7 +45,7 @@ const SECTIONS = [
accent: "#138ba0", accent: "#138ba0",
background: "#eef9fb", background: "#eef9fb",
Component: EventListCards, Component: EventListCards,
props: { section: "national" }, props: { section: "national", ...RETREATS },
views: { ...CARD_VIEWS, default: "carousel" }, views: { ...CARD_VIEWS, default: "carousel" },
}, },
{ {
@ -33,19 +55,56 @@ const SECTIONS = [
accent: "#aac992", accent: "#aac992",
background: "#ffffff", background: "#ffffff",
Component: EventListCards, Component: EventListCards,
props: { section: "regional" }, props: { section: "regional", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
{ {
id: "partner", id: "partner",
title: "Partner Events", title: "Partner Events",
blurb: "Events hosted by organizations we collaborate with.", blurb: "Retreats hosted by organizations we collaborate with.",
accent: "#7a5ea8", accent: "#7a5ea8",
background: "#eef9fb", background: "#eef9fb",
Component: EventListCards, Component: EventListCards,
props: { section: "partner" }, props: { section: "partner", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
/* The other three scopes, ready to uncomment. Each needs an accent
and a background of its own — those are presentation and live
here, not in event_sections.
{
id: "local",
title: "Local Events",
blurb: "Hosted by individual chapters.",
accent: "#d08a3c",
background: "#ffffff",
Component: EventListCards,
props: { section: "local", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},
{
id: "international",
title: "International Events",
blurb: "Gatherings beyond the US.",
accent: "#3c7fd0",
background: "#eef9fb",
Component: EventListCards,
props: { section: "international", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},
{
id: "other",
title: "Other Events",
blurb: "Everything that doesn't fit the categories above.",
accent: "#7a8a8e",
background: "#ffffff",
Component: EventListCards,
props: { section: "other", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},
*/
]; ];
export default function RetreatsPage() { export default function RetreatsPage() {
@ -54,7 +113,7 @@ export default function RetreatsPage() {
return ( return (
<PageShell <PageShell
title="Retreats" title="Retreats"
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year." intro="Retreats hosted by Next Generation of Unity, our regions, and our partners throughout the year."
sections={sections} sections={sections}
/> />
); );

155
src/pages/TeamDetail.tsx Normal file
View file

@ -0,0 +1,155 @@
/* ═══════════════════════════════════════════════════════════════
TEAM DETAIL — /teams/:id
teams.id is a global primary key rather than scoped to the
organization, which is what lets this route be flat: 'ngu-board'
can only mean one thing site-wide.
The roster is not in this page's data. PeopleTiles fetches
/teams/:id/people itself, off v_org_leadership, which already
decides who counts as current and public. Two requests, both
cached for 60s by api.js, and one set of visibility rules.
═══════════════════════════════════════════════════════════════ */
import { Link, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles from '../components/PeopleTiles.tsx'
import { useTeam } from '../lib/useContent.ts'
import { orgHref } from '../lib/hrefs.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function TeamDetail() {
const { id } = useParams()
const { data: team, loading, error, notFound, reload } = useTeam(id)
if (!team) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="team"
backTo="/leadership"
backLabel="Leadership"
/>
)
}
const accent = team.color || TEAL
const hasAbout =
team.description.length > 0 || team.blocks.length > 0 || team.links.length > 0
const sections: any[] = []
if (hasAbout) {
sections.push({
id: 'about',
title: 'About',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts orgId={team.org.id} orgKind={team.org.kind} orgName={team.org.name} accent={accent} />
{team.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={team.blocks} accent={accent} />
</div>
{team.links.length > 0 && (
<div className="flex flex-wrap gap-3">
{team.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
</div>
)}
</div>
),
})
}
sections.push({
id: 'members',
title: hasAbout ? 'Members' : team.name,
accent,
background: hasAbout ? '#eef9fb' : '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-6">
{!hasAbout && (
<Facts
orgId={team.org.id}
orgKind={team.org.kind}
orgName={team.org.name}
accent={accent}
/>
)}
<PeopleTiles
size="lg"
accent={accent}
teams={[{ id: team.id, label: 'Members', accent }]}
emptyMessage="Nobody is currently listed on this team."
/>
</div>
),
})
return (
<PageShell title={team.name} intro={team.tagline ?? undefined} sections={sections} />
)
}
function Facts({
orgId,
orgKind,
orgName,
accent,
}: {
orgId: string
orgKind?: string | null
orgName: string
accent: string
}) {
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
<span style={{ color: BODY }}>
A team of{' '}
<Link
to={orgHref(orgId, orgKind)}
className="font-medium hover:underline"
style={{ color: accent }}
>
{orgName}
</Link>
</span>
<Link
to={orgHref(orgId, orgKind)}
className="ml-auto hover:underline"
style={{ color: accent }}
>
Back to {orgName}
</Link>
</div>
)
}

View file

@ -2,8 +2,15 @@
ADMIN — FEEDBACK TRIAGE ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through Reads /api/admin/feedback, writes status and notes back through
PATCH. Deliberately a flat list rather than a table: the message PATCH, and deletes through DELETE. Deliberately a flat list
is the content, and messages don't fit in a cell. rather than a table: the message is the content, and messages
don't fit in a cell.
Two capabilities, two ranks. Editors and above can change a
status or leave a note; deleting is admin and above, matching
requireRole on the server. Both come from the roles ladder
rather than an equality check — a superadmin is not role ===
"admin", and reading it that way is what hid these controls.
Every read passes ttl: 0. The api cache exists for public Every read passes ttl: 0. The api cache exists for public
content that changes weekly; a triage queue two people are content that changes weekly; a triage queue two people are
@ -13,8 +20,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { get, patch, ApiError } from "../../lib/api.js"; import { del, get, patch, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js"; import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"]; const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@ -44,10 +52,11 @@ function locationOf(row) {
/* ── One submission ──────────────────────────────────────────── */ /* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, canWrite }) { function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? ""); const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? ""); const noteDirty = note !== (row.admin_note ?? "");
@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) {
} }
} }
// On success this card unmounts, so there's no finally here:
// busy only needs clearing on the path where the row survives.
async function remove() {
setBusy(true);
setError(null);
try {
await del(`/admin/feedback/${row.id}`);
onRemove(row);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
setConfirming(false);
setBusy(false);
}
}
return ( return (
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5"> <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"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) {
</option> </option>
))} ))}
</select> </select>
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
</div> </div>
<textarea <textarea
@ -150,6 +172,52 @@ function FeedbackCard({ row, onChange, canWrite }) {
)} )}
</div> </div>
)} )}
{/* Deleting is the irreversible option; marking something
spam or archived is the habit this defers to. Hence the
second click rather than a window.confirm. */}
{canRemove && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
{confirming ? (
<>
<span className="text-sm text-[#26454c]">
Delete #{row.id} for good? Marking it spam keeps it recoverable.
</span>
<button
type="button"
disabled={busy}
onClick={remove}
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
>
{busy ? "Deleting…" : "Delete"}
</button>
<button
type="button"
disabled={busy}
onClick={() => setConfirming(false)}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
>
Keep it
</button>
</>
) : (
<button
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</div>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
{error}
</p>
)}
</article> </article>
); );
} }
@ -170,7 +238,9 @@ export default function AdminFeedback() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const canWrite = user?.role === "admin"; // Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
const canRemove = canDelete(user);
const load = useCallback( const load = useCallback(
async (before = null) => { async (before = null) => {
@ -218,6 +288,16 @@ export default function AdminFeedback() {
setCounts((prev) => ({ ...prev })); // counts refresh on next load setCounts((prev) => ({ ...prev })); // counts refresh on next load
} }
// The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop.
function removeRow(removed) {
setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({
...prev,
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
}));
}
const tabs = [ const tabs = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })), ...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
@ -294,7 +374,9 @@ export default function AdminFeedback() {
key={row.id} key={row.id}
row={row} row={row}
canWrite={canWrite} canWrite={canWrite}
canRemove={canRemove}
onChange={replaceRow} onChange={replaceRow}
onRemove={removeRow}
/> />
))} ))}
</div> </div>

View file

@ -0,0 +1,182 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN HOME
Where login lands. Two columns: the cards are the navigation —
the header deliberately drops its tab row here so the same links
aren't drawn twice — and a standing info panel on the right.
The cards come from adminNav.js, the same list the CMS header
reads, so a new entity shows up here the moment it's registered.
Forms sit in their own block below: they're submissions coming
in rather than content going out, and there'll be more of them
than the feedback queue eventually. The Panel block below that
only exists for superadmins.
The right-hand panel is deliberately inert. Nothing here
fetches, so the landing page can't be slow or half-broken on
arrival; anything live (open feedback count, last-edited
record) wants to be a separate component that fails on its own.
═══════════════════════════════════════════════════════════════ */
import { Link } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
/* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting
an anchor inside an anchor; the sub-links sit above it on z-10 so
they stay separately clickable. */
function NavCard({ item }) {
const to = target(item);
// Drop the child that just repeats the card's own destination —
// "All organizations" under Organizations.
const extras = (item.children ?? []).filter((child) => child.to !== to);
return (
<div className="group relative flex flex-col rounded-2xl border border-[#138ba0]/20 bg-white p-5 transition-all hover:border-[#138ba0]/60 hover:shadow-sm">
<div className="flex items-baseline gap-3">
<h3 className="text-base font-semibold text-[#138ba0]">
<Link
to={to}
className="after:absolute after:inset-0 after:rounded-2xl after:content-['']"
>
{item.label}
</Link>
</h3>
<span
aria-hidden="true"
className="ml-auto text-[#138ba0] opacity-0 transition-opacity group-hover:opacity-100"
>
→
</span>
</div>
{item.blurb && (
<p className="mt-1.5 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
)}
{extras.length > 0 && (
<div className="relative z-10 mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-[#138ba0]/10 pt-3 text-sm">
{extras.map((child) => (
<Link
key={child.to}
to={child.to}
className="text-[#4a6b72] underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
}
function CardBlock({ title, blurb, children }) {
return (
<div className="mt-10">
<div className="flex items-baseline gap-3">
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
{blurb && <p className="text-sm text-[#4a6b72]">{blurb}</p>}
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">{children}</div>
</div>
);
}
function PanelSection({ title, children }) {
return (
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
{title}
</h2>
<div className="mt-2.5 text-sm text-[#4a6b72]">{children}</div>
</section>
);
}
const QUICK_ADD = [
{ to: "/admin/events/new", label: "New event" },
{ to: "/admin/people/new", label: "New person" },
{ to: "/admin/organizations/new", label: "New organization" },
{ to: "/admin/timeline/new", label: "New timeline entry" },
];
export default function AdminHome() {
const { user } = useAuth();
const role = ROLE_LABELS[user?.role] ?? user?.role;
return (
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
<div>
<h1 className="text-2xl font-bold text-[#138ba0]">
{user?.name ? `Welcome back, ${user.name.split(" ")[0]}.` : "Welcome back."}
</h1>
<p className="mt-1 text-[#4a6b72]">Pick a section to work in.</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{CMS_NAV.map((item) => (
<NavCard key={item.label} item={item} />
))}
</div>
<CardBlock title={FORMS_NAV.label} blurb={FORMS_NAV.blurb}>
{FORMS_NAV.children.map((form) => (
<NavCard key={form.to} item={form} />
))}
</CardBlock>
{isSuper(user) && (
<CardBlock title="Superadmin" blurb="Only superadmins see this block.">
<NavCard item={PANEL_NAV} />
</CardBlock>
)}
</div>
{/* Sticky so it stays put once the card column outgrows it. */}
<aside className="divide-y divide-[#138ba0]/10 rounded-2xl border border-[#138ba0]/20 bg-white lg:sticky lg:top-6">
<PanelSection title="Signed in">
<p className="font-medium text-[#0f2f36]">{user?.name || user?.email}</p>
{user?.name && user?.email && (
<p className="mt-0.5 break-all text-xs text-[#4a6b72]/80">{user.email}</p>
)}
{role && (
<span className="mt-2 inline-block rounded-full bg-[#eef9fb] px-2.5 py-0.5 text-xs font-medium text-[#138ba0]">
{role}
</span>
)}
</PanelSection>
<PanelSection title="Start something">
<ul className="space-y-1.5">
{QUICK_ADD.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
{link.label}
</Link>
</li>
))}
</ul>
</PanelSection>
<PanelSection title="Site">
<p className="font-mono text-xs text-[#4a6b72]/80">{SITE_VERSION}</p>
<a
href="/"
target="_blank"
rel="noreferrer"
className="mt-2 inline-block underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
View the public site ↗
</a>
</PanelSection>
</aside>
</div>
);
}

View file

@ -5,59 +5,45 @@
footer site map — none of that belongs around a staff tool, and footer site map — none of that belongs around a staff tool, and
/admin should never appear in navConfig. /admin should never appear in navConfig.
The nav is two tiers, the same shape as the public header: a Three areas share this chrome: Home, the CMS and the Panel. The
primary row of the things you'd go looking for, and a subnav of wordmark names whichever one you're in, and from anywhere but
whatever sits under the one you're in. Teams and Awards live Home it's the way back to Home.
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 The tab row is drawn everywhere except Home, where the card grid
which tab lights up. Adding an entity is an entry here plus a is the navigation and drawing both would say the same thing
descriptor; there's no second list to keep in step. twice. Which tabs appear depends on the signed-in user —
navFor() drops the superadmin-only ones — but that's cosmetics.
The route guard and the API are what actually say no.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx"; import { useAuth } from "../../lib/auth.tsx";
import { AdminTitleContext } from "../../lib/adminTitle.tsx"; import { AdminTitleContext } from "../../lib/adminTitle.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
const SITE_TITLE = "NGU Admin CMS"; import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import nguLogo from "../../assets/NGU_Logo.svg";
// A group with no `to` of its own opens its first child, so import {
// clicking the word Forms goes somewhere rather than nowhere. ADMIN_HOME,
const NAV = [ AREA_TITLES,
{ to: "/admin/events", label: "Events" }, areaFor,
{ matches,
to: "/admin/organizations", navFor,
label: "Organizations", target,
children: [ } from "./adminNav.js";
{ 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() { export default function AdminLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const active = NAV.find( const area = areaFor(pathname);
const areaTitle = AREA_TITLES[area];
const isHome = area === "home";
const nav = useMemo(() => navFor(user), [user]);
const active = nav.find(
(item) => (item) =>
matches(pathname, item.to) || matches(pathname, item.to) ||
(item.children ?? []).some((child) => matches(pathname, child.to)), (item.children ?? []).some((child) => matches(pathname, child.to)),
@ -75,9 +61,12 @@ export default function AdminLayout() {
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]); const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => { useEffect(() => {
const section = activeChild?.label ?? active?.label; // Sections only exist in the CMS. Home and Panel already say
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | "); // what they are in the area title; "Panel | NGU Admin Panel"
}, [active, activeChild, detail]); // would just stutter.
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
}, [area, areaTitle, active, activeChild, detail]);
async function handleLogout() { async function handleLogout() {
await logout(); await logout();
@ -86,14 +75,28 @@ export default function AdminLayout() {
const subnav = active?.children ?? []; const subnav = active?.children ?? [];
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
return ( return (
<div className="min-h-screen bg-[#f6fbfc]"> <div className="flex min-h-screen flex-col bg-[#f6fbfc]">
<header className="border-b border-[#138ba0]/20 bg-white"> <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"> <div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span> {/* Already home, so nothing to link to. */}
{isHome ? (
wordmark
) : (
<Link
to={ADMIN_HOME}
className="rounded transition-opacity hover:opacity-70"
title="Back to the admin home"
>
{wordmark}
</Link>
)}
{!isHome && (
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
{NAV.map((item) => ( {nav.map((item) => (
<div key={item.label} className="flex items-center gap-6"> <div key={item.label} className="flex items-center gap-6">
{item.separated && ( {item.separated && (
<span <span
@ -114,9 +117,12 @@ export default function AdminLayout() {
</div> </div>
))} ))}
</nav> </nav>
)}
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]"> <div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
<span>{user?.name || user?.email}</span> <span>
{user?.name || user?.email}
</span>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
@ -150,11 +156,25 @@ export default function AdminLayout() {
)} )}
</header> </header>
<main className="mx-auto max-w-5xl px-6 py-10"> {/* flex-1 rather than a fixed height: the footer sits at the
bottom of a short page and below the content of a long one,
without ever floating over it. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
<AdminTitleContext.Provider value={titleContext}> <AdminTitleContext.Provider value={titleContext}>
<Outlet /> <Outlet />
</AdminTitleContext.Provider> </AdminTitleContext.Provider>
</main> </main>
<footer className="border-t border-[#138ba0]/15 bg-white">
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
</Link>
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
{SITE_VERSION}
</span>
</div>
</footer>
</div> </div>
); );
} }

View file

@ -32,7 +32,7 @@ export default function AdminLogin() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/feedback"; const destination = location.state?.from?.pathname ?? "/admin/home";
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();

View file

@ -0,0 +1,331 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN PANEL
Superadmin only, guarded by RequireRole on the route and by
requireRole("superadmin") on every endpoint it calls. This page
assumes neither: it renders whatever the API gives it and shows
whatever the API refuses.
Three blocks, deliberately boring:
System — what's actually running, for when something is off
Content — row counts, the cheapest "is the database there"
Accounts — roles, access and live sessions
The role select is driven by ROLES from lib/roles.ts, so a new
rung on the ladder appears here without this file changing. The
legend beside it is the same list — four roles is past the
point where "Editor" explains itself.
Every account write signs that person out, which the server
does rather than this page. Two things you can't do here: edit
your own row, or take the last active superadmin away. Both are
enforced server-side and mirrored in the disabled states, so
the reason shows up before the click rather than after it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { del, get, patch } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useNavigate } from "react-router-dom";
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
marker that says so. Left alone, browsers read it as local time
and last-login drifts by the timezone offset. */
function when(value) {
if (!value) return "—";
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function uptime(seconds) {
if (seconds == null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
return `${m}m`;
}
function Block({ title, note, children }) {
return (
<section className="mt-8 first:mt-0">
<div className="flex items-baseline gap-3">
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
{note && <p className="text-sm text-[#4a6b72]">{note}</p>}
</div>
<div className="mt-3">{children}</div>
</section>
);
}
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
{label}
</div>
<div className="mt-1 break-words text-sm font-medium text-[#0f2f36]">{value}</div>
</div>
);
}
export default function AdminPanel() {
const { user: me } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Which row is mid-request, and what went wrong on it. Scoped to
// the row so a failure on one account doesn't blank the table.
const [busyId, setBusyId] = useState(null);
const [rowError, setRowError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await get("/admin/panel/overview", { ttl: 0 }));
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError(err.message || "Couldn't load the panel.");
} finally {
setLoading(false);
}
}, [navigate]);
useEffect(() => {
load();
}, [load]);
/* Replace the one row the server returns rather than refetching
the whole overview — the counts didn't change. */
function mergeUser(updated) {
setData((current) =>
current
? {
...current,
users: current.users.map((u) => (u.id === updated.id ? updated : u)),
}
: current,
);
}
async function run(id, work) {
setBusyId(id);
setRowError(null);
try {
mergeUser(await work());
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setRowError({ id, message: err.message || "That didn't work." });
} finally {
setBusyId(null);
}
}
const changeRole = (row, role) =>
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
const setActive = (row, is_active) =>
run(
row.id,
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const revoke = (row) =>
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
if (loading) {
return (
<p className="py-12 text-[#4a6b72]" role="status">
Loading the panel…
</p>
);
}
if (error) {
return (
<div className="py-12">
<p className="text-[#b3261e]">{error}</p>
<button
type="button"
onClick={load}
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
Try again
</button>
</div>
);
}
const { system, content, users } = data;
const activeSupers = users.filter(
(u) => u.role === "superadmin" && u.is_active === 1,
).length;
return (
<div>
<h1 className="text-2xl font-bold text-[#138ba0]">Panel</h1>
<p className="mt-1 text-[#4a6b72]">
Accounts and server state. Everything here is superadmin-only.
</p>
<Block title="System">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat label="Schema version" value={system.schemaVersion} />
<Stat label="API uptime" value={uptime(system.uptimeSeconds)} />
<Stat label="Started" value={when(system.startedAt)} />
<Stat label="Node" value={system.nodeVersion} />
<Stat label="Platform" value={system.platform} />
<Stat label="Live sessions" value={system.sessions} />
</div>
{system.dbPath && (
<p className="mt-3 font-mono text-xs text-[#4a6b72]/70">{system.dbPath}</p>
)}
</Block>
<Block title="Content" note="Row counts, straight from the tables.">
<div className="flex flex-wrap gap-2">
{content.map((row) => (
<span
key={row.label}
className="rounded-full border border-[#138ba0]/20 bg-white px-3 py-1 text-sm text-[#4a6b72]"
>
{row.label}{" "}
<strong className="font-semibold text-[#0f2f36]">
{row.count ?? "—"}
</strong>
</span>
))}
</div>
</Block>
<Block
title="Accounts"
note="Changing a role or disabling an account signs that person out."
>
<div className="overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
<table className="w-full min-w-[46rem] text-left text-sm">
<thead className="border-b border-[#138ba0]/15 text-xs uppercase tracking-wider text-[#4a6b72]/70">
<tr>
<th className="px-4 py-3 font-medium">Account</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Last login</th>
<th className="px-4 py-3 font-medium">Sessions</th>
<th className="px-4 py-3 font-medium">Access</th>
</tr>
</thead>
<tbody className="divide-y divide-[#138ba0]/10">
{users.map((row) => {
const isMe = row.id === me?.id;
const lastSuper =
row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1;
const locked = isMe || lastSuper;
const busy = busyId === row.id;
return (
<tr key={row.id} className={row.is_active ? "" : "bg-[#f6fbfc]"}>
<td className="px-4 py-3">
<div className="font-medium text-[#0f2f36]">
{row.name || row.email}
{isMe && (
<span className="ml-2 text-xs font-normal text-[#4a6b72]/70">
you
</span>
)}
</div>
{row.name && (
<div className="text-xs text-[#4a6b72]/80">{row.email}</div>
)}
{rowError?.id === row.id && (
<div className="mt-1 text-xs text-[#b3261e]">
{rowError.message}
</div>
)}
</td>
<td className="px-4 py-3">
<select
value={row.role}
disabled={locked || busy}
onChange={(e) => changeRole(row, e.target.value)}
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
title={
isMe
? "You can't change your own role."
: lastSuper
? "The last active superadmin can't be demoted."
: ROLE_NOTES[row.role]
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-[#4a6b72]">{when(row.last_login_at)}</td>
<td className="px-4 py-3">
<span className="text-[#4a6b72]">{row.sessions}</span>
{row.sessions > 0 && (
<button
type="button"
disabled={busy}
onClick={() => revoke(row)}
className="ml-3 text-xs font-medium text-[#138ba0] underline-offset-4 hover:underline disabled:opacity-50"
>
Sign out
</button>
)}
</td>
<td className="px-4 py-3">
<button
type="button"
disabled={locked || busy}
onClick={() => setActive(row, row.is_active ? 0 : 1)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
row.is_active
? "border-[#4a6b72]/30 text-[#4a6b72] hover:border-[#b3261e]/50 hover:text-[#b3261e]"
: "border-[#138ba0]/40 text-[#138ba0] hover:bg-[#eef9fb]"
}`}
>
{row.is_active ? "Disable" : "Enable"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Each rung adds to the one above it. Worth stating, because
"Editor" doesn't tell you where the line falls. */}
<dl className="mt-4 grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
{ROLES.map((role) => (
<div key={role} className="flex gap-2">
<dt className="shrink-0 font-medium text-[#0f2f36]">
{ROLE_LABELS[role]}
</dt>
<dd className="text-[#4a6b72]">{ROLE_NOTES[role]}</dd>
</div>
))}
</dl>
<p className="mt-4 text-xs text-[#4a6b72]/80">
New accounts are still created with <code>admin-cli.js</code> on the server.
</p>
</Block>
</div>
);
}

View file

@ -23,6 +23,16 @@
updated_at column simply never get one, and the 409 path stays updated_at column simply never get one, and the 409 path stays
dormant for them. dormant for them.
Two capabilities, not one role. An editor may create and update
but not delete, so the action bar asks canWrite/canDelete rather
than comparing user.role to a string. The comparison this
replaced — role === "admin" — locked superadmins out of saving
the moment a rank above admin existed, which is what an equality
test against a ladder always eventually does.
None of this is protection. The server refuses the request; this
only decides whether to draw a button that would be refused.
slugFrom may name one field or several. Most ids are unique slugFrom may name one field or several. Most ids are unique
because the name is: two organizations aren't both called because the name is: two organizations aren't both called
Northwest. Team ids are the exception — teams.id is a global Northwest. Team ids are the exception — teams.id is a global
@ -37,6 +47,7 @@ import { get, post, patch, del, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.tsx"; import { useAdminDetail } from "../../lib/adminTitle.tsx";
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js"; import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx"; 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 /* A foreign key refusing to budge is the most common way a save or
@ -56,7 +67,14 @@ export default function EntityEdit() {
const { user } = useAuth(); const { user } = useAuth();
const isNew = id === "new"; const isNew = id === "new";
const canWrite = user?.role === "admin"; const canWrite = atLeast(user, "editor");
const canDelete = atLeast(user, "admin");
// Some entities have no slug: the table assigns an integer id, so
// there is nothing to type on create and nothing to compose from
// other fields. Timeline entries are the first — an entry that
// references an event has no name of its own.
const autoId = manifest?.idKind === "auto";
// Hoisted above the loading guards: the title hook below is a // 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 // hook, so it can't sit after an early return, and it needs the
@ -92,8 +110,9 @@ export default function EntityEdit() {
}; };
// The heading wants the specific half, not the qualifier: a team // The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board". // page reads "Board", not "northwest Board". An entity with no slug
const headingPath = slugPaths[slugPaths.length - 1]; // names the field to read instead.
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
const [form, setForm] = useState(null); const [form, setForm] = useState(null);
const [options, setOptions] = useState({}); const [options, setOptions] = useState({});
@ -120,7 +139,9 @@ export default function EntityEdit() {
// touch", which is wrong for a row that doesn't exist yet. // touch", which is wrong for a row that doesn't exist yet.
// The parent's own fields stay absent on purpose so the // The parent's own fields stay absent on purpose so the
// server's column defaults apply to whatever isn't filled in. // server's column defaults apply to whatever isn't filled in.
const blank = { id: "" }; // No id key for an auto entity: the table assigns it, and
// sending "" would be an explicit value rather than an absence.
const blank = autoId ? {} : { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = []; for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank); setForm(blank);
baseline.current = JSON.stringify(blank); baseline.current = JSON.stringify(blank);
@ -139,7 +160,7 @@ export default function EntityEdit() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [manifest, id, isNew, navigate]); }, [manifest, id, isNew, autoId, navigate]);
useEffect(() => { useEffect(() => {
load(); load();
@ -288,7 +309,19 @@ export default function EntityEdit() {
{isNew ? `New ${manifest.singular}` : heading} {isNew ? `New ${manifest.singular}` : heading}
</h1> </h1>
{/* Slug */} {/* Slug. An auto-id entity has nothing to ask for on create, and
nothing editable afterwards — so it gets a plain line rather
than a disabled box pretending to be a field. */}
{autoId ? (
!isNew && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
{manifest.idLabel} #{form.id}
{form.updated_at && <> · last saved {form.updated_at}</>}
</p>
</div>
)
) : (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"> <div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field <Field
field={{ field={{
@ -317,6 +350,7 @@ export default function EntityEdit() {
</p> </p>
)} )}
</div> </div>
)}
{/* Field groups */} {/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => ( {manifest.groups.filter((group) => visible(group.when)).map((group) => (
@ -332,6 +366,7 @@ export default function EntityEdit() {
<Field <Field
key={field.path} key={field.path}
field={field} field={field}
row={form}
value={getPath(form, field.path)} value={getPath(form, field.path)}
options={options} options={options}
error={errors[field.path]} error={errors[field.path]}
@ -376,7 +411,7 @@ export default function EntityEdit() {
> >
{saving ? "Saving…" : isNew ? "Create" : "Save changes"} {saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button> </button>
{!isNew && ( {!isNew && canDelete && (
<button <button
type="button" type="button"
onClick={remove} onClick={remove}
@ -388,7 +423,7 @@ export default function EntityEdit() {
</> </>
) : ( ) : (
<span className="text-sm text-[#4a6b72]"> <span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes. Read-only: your account can view this but not change it.
</span> </span>
)} )}

View file

@ -4,6 +4,12 @@
One component for organizations, events and people. The :entity One component for organizations, events and people. The :entity
route param picks the manifest; nothing here knows what a route param picks the manifest; nothing here knows what a
chapter or a retreat is. chapter or a retreat is.
The "New X" link follows the same rule as EntityEdit's save
button: atLeast(user, "editor"), matching requireRole("editor")
on POST /api/admin/:entity. Drawing it is not permission — the
server decides — it only avoids offering a click that 403s, and
avoids hiding one that wouldn't.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -12,6 +18,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { get, ApiError } from "../../lib/api.js"; import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js"; import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
export default function EntityList() { export default function EntityList() {
const { entity: entityKey } = useParams(); const { entity: entityKey } = useParams();
@ -26,7 +33,12 @@ export default function EntityList() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [query, setQuery] = useState(params.get("q") ?? ""); const [query, setQuery] = useState(params.get("q") ?? "");
const canWrite = user?.role === "admin"; // Minimum rank, never equality. POST /api/admin/:entity is gated
// at "editor", so anyone from editor upward may create — and the
// equality test this replaced hid the button from superadmins as
// well as editors, which is the failure mode an == against a
// ladder always produces once a rank is added above it.
const canWrite = atLeast(user, "editor");
const load = useCallback(async () => { const load = useCallback(async () => {
if (!manifest) return; if (!manifest) return;

View file

@ -0,0 +1,34 @@
/* ═══════════════════════════════════════════════════════════════
ROLE GUARD — src/pages/admin/RequireRole.tsx
Sits inside RequireAuth, never instead of it: by the time this
renders, the session question has already been answered. All
this decides is whether the answer was good enough.
Same caveat as RequireAuth — this hides the interface, not the
data. /api/admin/panel/* is superadmin-only on the server, and
that's the part that matters. Without it, a bookmarked URL and
a disabled select would be the only thing between a viewer and
the account list.
Bounces to Home rather than showing a "denied" page. Someone
who lands here has almost always followed a stale link, and a
working page beats an explanation of one.
═══════════════════════════════════════════════════════════════ */
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { atLeast } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.js";
export default function RequireRole({ role = "superadmin" }) {
const { user, loading } = useAuth();
// RequireAuth is already showing its own placeholder above this.
if (loading) return null;
if (!user) return <Navigate to="/admin/login" replace />;
if (!atLeast(user, role)) return <Navigate to={ADMIN_HOME} replace />;
return <Outlet />;
}

114
src/pages/admin/adminNav.js Normal file
View file

@ -0,0 +1,114 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN NAVIGATION — src/pages/admin/adminNav.js
Lifted out of AdminLayout because two things read it now: the
header tabs and the card grid on the home page. Adding an entity
stays one entry here plus a descriptor — there's still no second
list to keep in step, it just isn't inside the layout file
any more.
`blurb` is only read by the home cards. The header ignores it.
`superOnly` hides an entry from anyone below superadmin. It
hides, nothing more: the route still has to be guarded and the
API still has to say no. Treating a filtered menu as access
control is how you end up with a URL that works.
Three areas, three titles. The area is derived from the path
rather than declared per route, so a page added under
/admin/panel/… inherits the right title without registering
anything.
═══════════════════════════════════════════════════════════════ */
import { isSuper } from "../../lib/roles.ts";
export const ADMIN_HOME = "/admin/home";
export const ADMIN_PANEL = "/admin/panel";
export const AREA_TITLES = {
home: "NGU Admin Home",
cms: "NGU Admin CMS",
panel: "NGU Admin Panel",
};
/* The CMS tabs. 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. */
export const CMS_NAV = [
{
to: "/admin/events",
label: "Events",
blurb: "Retreats, conferences and gatherings, with their sections and rosters.",
},
{
to: "/admin/organizations",
label: "Organizations",
blurb: "Regions, chapters and partners — plus the teams and awards they own.",
children: [
{ to: "/admin/organizations", label: "All organizations" },
{ to: "/admin/teams", label: "Teams" },
{ to: "/admin/awards", label: "Awards" },
],
},
{
to: "/admin/people",
label: "People",
blurb: "Bios, contact details, affiliations and awards received.",
},
// Its own tab rather than a child of anything: a timeline entry can
// point at an event, an organization, an award, a person or a team,
// so filing it under one of them would be arbitrary.
{
to: "/admin/timeline",
label: "Timeline",
blurb: "What the history page shows, and the order it shows it in.",
},
];
/* Forms are submissions coming in rather than content going out, so
they get their own group in the header and their own block on the
home page. A group with no `to` of its own opens its first child,
so clicking the word Forms goes somewhere rather than nowhere. */
export const FORMS_NAV = {
label: "Forms",
blurb: "Whatever the public site has sent us.",
separated: true,
children: [
{
to: "/admin/feedback",
label: "Website feedback",
blurb: "The triage queue for the feedback form.",
},
],
};
/* Accounts and server state, not content — which is why it sits
outside the CMS rather than as another tab within it. */
export const PANEL_NAV = {
to: ADMIN_PANEL,
label: "Panel",
blurb: "Accounts, sessions and the state of the server.",
separated: true,
superOnly: true,
};
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
/* What this user may see. Call it with the user from useAuth. */
export function navFor(user) {
return NAV.filter((item) => !item.superOnly || isSuper(user));
}
/* A tab owns its own page and everything below it, so editing
/admin/teams/ngu-board keeps Teams lit. */
export const matches = (pathname, to) =>
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
export const target = (item) => item.to ?? item.children?.[0]?.to;
export function areaFor(pathname) {
if (matches(pathname, ADMIN_HOME)) return "home";
if (matches(pathname, ADMIN_PANEL)) return "panel";
return "cms";
}

View file

@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { splitByStatus, useEvents } from "../../data/eventData.js"; import { Link } from "react-router-dom";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS EVENT LIST — CARDS
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
<EventListCards section="national" view="carousel" /> <EventListCards section="national" view="carousel" />
<EventListCards host="northwest" view="grid" /> <EventListCards host="northwest" view="grid" />
<EventListCards type={["class", "workshop"]} view="grid" />
`view` and `accent` come from the page's section manifest. `view` and `accent` come from the page's section manifest.
`type` pre-filters the band the way `section` and `host` do. Left
off, the band takes every kind it finds and grows a row of chips
to narrow by — but only once it holds more than one, so a band of
nothing but retreats shows no control at all.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", { const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
</svg> </svg>
); );
/* The title is the way into the event's own page.
A link on the title rather than a wrapper around the whole card:
the footer already holds anchors, and an anchor inside an anchor
is invalid markup that every browser resolves by guessing. The
carousel has the same constraint — it needs the card's click to
mean "bring this one to the front" on anything that isn't the
active slide. */
function TitleLink({ ev, linked, children }) {
if (!linked) return <>{children}</>;
return (
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
{children}
</Link>
);
}
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes. EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel compact=false → the full card used in the carousel
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
Fields arrive pre-resolved from the API — `color` is the event's Fields arrive pre-resolved from the API — `color` is the event's
own or its host's, `status` is derived from the dates when it own or its host's, `status` is derived from the dates when it
isn't set — so nothing here reimplements those rules. isn't set — so nothing here reimplements those rules.
`linked` is the one thing a caller turns off: a card on the
event's own page shouldn't link to the page it's already on.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
export function Card({ export function Card({
ev, ev,
@ -126,6 +155,8 @@ export function Card({
accent = TEAL, accent = TEAL,
compact = false, compact = false,
interactive = true, interactive = true,
linked = true,
showType = false,
}) { }) {
const past = ev.status === "past"; const past = ev.status === "past";
const color = ev.color || defaultColor; const color = ev.color || defaultColor;
@ -137,6 +168,19 @@ export function Card({
? `https://instagram.com/${igHandle.replace(/^@/, "")}` ? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null; : null;
/* Off unless the caller says the band is mixed. A "Retreat" badge
on every card in a row of nothing but retreats is noise, and
the card can't tell on its own — it only ever sees one event. */
const typeBadge =
showType && ev.event_type ? (
<span
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
style={{ border: `1px solid ${color}`, color }}
>
{eventTypeLabel(ev.event_type)}
</span>
) : null;
/* An ordered array, so a card can carry one paragraph or five /* An ordered array, so a card can carry one paragraph or five
without the component changing. */ without the component changing. */
const descriptions = (ev.description ?? []).map((text, i) => ( const descriptions = (ev.description ?? []).map((text, i) => (
@ -168,7 +212,12 @@ export function Card({
<div className="ev-grid mb-2"> <div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" /> <Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info"> <div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3> {typeBadge}
<h3 className="text-3xl font-900 leading-tight">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && ( {ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p> <p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)} )}
@ -190,7 +239,12 @@ export function Card({
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" /> <Logo file={orgLogo} className="h-15 w-auto mb-6" />
<h3 className="text-4xl font-900">{ev.title}</h3> {typeBadge}
<h3 className="text-4xl font-900">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && ( {ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p> <p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)} )}
@ -211,10 +265,32 @@ export function Card({
</> </>
)} )}
{/* Footer — mt-auto pins it to the bottom so buttons line up {/* Footer — mt-auto pins the whole block to the bottom so
across every card in a grid row. */} buttons line up across every card in a grid row.
{links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}> Three registration states, as before: links to follow, a
past event, or an announcement still to come. What's new
is that all three end in the same row, because every
event now has a page and a past one is often the more
worth reading — speakers, awards, what actually
happened. The notice is what changes; the way in
doesn't. */}
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
{links.length === 0 && (
<p className="text-center font-600" style={{ color: accent }}>
{past
? "This event has concluded — thank you to everyone who joined us!"
: igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
)}
<div
className={`flex flex-wrap justify-center items-center ${
compact ? "gap-2" : "gap-3"
} ${links.length === 0 ? "mt-4" : ""}`}
>
{links.map(item => ( {links.map(item => (
<a <a
key={item.label} key={item.label}
@ -227,28 +303,17 @@ export function Card({
{item.label} {item.label}
</a> </a>
))} ))}
</div>
) : past ? ( {/* Only when there's nothing to register for and the
<p event hasn't happened — the same condition as before,
className="mt-auto text-center font-600 pt-8" just no longer nested inside that branch. */}
style={{ color: accent }} {igHandle && !past && links.length === 0 && (
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
<a <a
href={igUrl} href={igUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${ className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6" compact ? "py-2.5 px-5" : "py-3 px-6"
}`} }`}
style={{ border: `1px solid ${accent}`, color: accent }} style={{ border: `1px solid ${accent}`, color: accent }}
> >
@ -256,10 +321,67 @@ export function Card({
{igHandle} {igHandle}
</a> </a>
)} )}
</div>
{/* Last, so Register reads first when there is one. */}
{linked && (
<Link
to={eventHref(ev.id)}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
Event details
</Link>
)} )}
</div> </div>
</div> </div>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
TYPE FILTER
Drawn only when a band actually holds more than one kind, so it
costs nothing today — every event is a retreat — and appears on
its own the first time a class or a workshop lands in that band.
Nothing on Retreats.tsx has to be reconfigured for it.
Chips rather than a select: with four or five options, all of
them visible is one tap, and the row reads as what the section
contains rather than as a form control.
═══════════════════════════════════════════════════════════════ */
export function TypeFilter({ types, active, setActive, accent }) {
const chip = on => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id, label) => (
<button
key={id}
onClick={() => setActive(id)}
aria-pressed={active === id}
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
style={chip(active === id)}
>
{label}
</button>
);
return (
<div
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
style={{ maxWidth: GRID_MAX }}
role="group"
aria-label="Filter events by type"
>
{button("all", "All")}
{types.map(entry => button(entry.id, entry.plural))}
</div>
); );
} }
@ -323,22 +445,57 @@ export default function EventListCards({
section, section,
host, host,
status, status,
type,
view = "carousel", view = "carousel",
accent = TEAL, accent = TEAL,
defaultColor, defaultColor,
empty = "· Events coming soon, stay connected for announcements ·", empty = "· Events coming soon, stay connected for announcements ·",
}) { }) {
const { events, loading, error } = useEvents({ section, host, status }); const { events: fetched, loading, error } = useEvents({
section,
host,
status,
type,
});
const cardColor = defaultColor ?? accent; const cardColor = defaultColor ?? accent;
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [showPast, setShowPast] = useState(false); const [showPast, setShowPast] = useState(false);
const [activeType, setActiveType] = useState("all");
/* What this band holds, which is what the chips offer — not the
full list of declared types, three quarters of which would be
dead buttons. */
const availableTypes = useMemo(
() => typesPresent(fetched, EVENT_TYPES),
[fetched],
);
const mixed = availableTypes.length > 1;
const events = useMemo(
() =>
activeType === "all"
? fetched
: fetched.filter(e => e.event_type === activeType),
[fetched, activeType],
);
/* Open on the first upcoming event. The list is empty on the /* Open on the first upcoming event. The list is empty on the
first render, so this can't be a useState initialiser — it has first render, so this can't be a useState initialiser — it has
to wait for the data and then run once. Clearing the guard when to wait for the data and then run once. Clearing the guard when
the list empties means a refetch re-seeds. */ the list empties means a refetch re-seeds. */
const seeded = useRef(false); const seeded = useRef(false);
/* Changing the chip is a different list, so the carousel re-seeds
on the first upcoming event of that kind rather than holding an
index that may now be past the end. Declared before the seed
effect so the guard is already clear when it runs. */
useEffect(() => {
seeded.current = false;
setIndex(0);
}, [activeType]);
useEffect(() => { useEffect(() => {
if (events.length === 0) { if (events.length === 0) {
seeded.current = false; seeded.current = false;
@ -395,8 +552,24 @@ export default function EventListCards({
return ( return (
<div className="overflow-hidden"> <div className="overflow-hidden">
{mixed && (
<TypeFilter
types={availableTypes}
active={activeType}
setActive={setActiveType}
accent={accent}
/>
)}
{events.length === 0 ? ( {events.length === 0 ? (
notice(empty) /* Two different empties. Nothing scheduled is news; nothing
of the kind you just picked is a filter you can undo, and
the chips are still on screen to undo it with. */
notice(
activeType === "all"
? empty
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
)
) : view === "grid" ? ( ) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */ /* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}> <div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
@ -412,6 +585,7 @@ export default function EventListCards({
defaultColor={cardColor} defaultColor={cardColor}
accent={accent} accent={accent}
compact compact
showType={mixed}
/> />
))} ))}
</div> </div>
@ -450,6 +624,7 @@ export default function EventListCards({
accent={accent} accent={accent}
compact compact
interactive={showPast} interactive={showPast}
showType={mixed}
/> />
))} ))}
</div> </div>
@ -514,6 +689,7 @@ export default function EventListCards({
defaultColor={cardColor} defaultColor={cardColor}
accent={accent} accent={accent}
interactive={active} interactive={active}
showType={mixed}
/> />
</div> </div>
); );

View file

@ -0,0 +1,142 @@
import { useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
defaultOpenYears,
groupTimeline,
groupYears,
partitionByDate,
type DecadeMeta,
type SortDirection,
type TimelineItem,
} from '../../../lib/timeline'
import TimelineDecade from './TimelineDecade'
import TimelineUpcoming from './TimelineUpcoming'
import './timeline.css'
const PARAM = 'y'
type Props = {
items: TimelineItem[]
decades: DecadeMeta[]
direction?: SortDirection
/** Decades ending before this year get the pre-program treatment. */
programStartYear?: number
fillGaps?: boolean
/** Injectable for tests and for pinning a date in a screenshot. */
now?: Date
}
export default function HistoryTimeline({
items,
decades,
direction = 'desc',
programStartYear = 2002,
fillGaps = true,
now,
}: Props) {
const [searchParams, setSearchParams] = useSearchParams()
// Upcoming vs recorded is a function of the clock, so an event crosses
// over on its own date with nothing to flip.
const { upcoming, past } = useMemo(
() => partitionByDate(items, now ?? new Date()),
[items, now],
)
const upcomingYears = useMemo(
() => groupYears(upcoming, { direction }),
[upcoming, direction],
)
const grouped = useMemo(
() => groupTimeline(past, decades, { direction, programStartYear }),
[past, decades, direction, programStartYear],
)
// `?y=2014,2022` deep-links straight to open years. Absence of the
// param means "not chosen yet", so fall back to the most recent
// featured year; an empty param means everything was collapsed on
// purpose.
const openYears = useMemo(() => {
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
const raw = searchParams.get(PARAM) ?? ''
return new Set(
raw
.split(',')
.map((part) => Number(part.trim()))
.filter((n) => Number.isInteger(n)),
)
}, [searchParams, grouped])
const commit = useCallback(
(next: Set<number>) => {
const params = new URLSearchParams(searchParams)
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
// replace: toggling shouldn't fill the back button with history.
setSearchParams(params, { replace: true })
},
[searchParams, setSearchParams],
)
const toggleYear = useCallback(
(year: number) => {
const next = new Set(openYears)
if (next.has(year)) next.delete(year)
else next.add(year)
commit(next)
},
[openYears, commit],
)
const allYears = useMemo(() => {
const years = grouped.flatMap((decade) =>
decade.years.filter((y) => y.count > 0).map((y) => y.year),
)
return [...years, ...upcomingYears.map((y) => y.year)]
}, [grouped, upcomingYears])
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
if (grouped.length === 0 && upcomingYears.length === 0) {
return (
<div className="ngu-tl">
<p className="ngu-tl__emptyState">
The timeline is empty. Add a milestone to start the record.
</p>
</div>
)
}
return (
<div className="ngu-tl">
<div className="ngu-tl__toolbar">
<button
type="button"
className="ngu-tl__toolbarBtn"
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
>
{allOpen ? 'Collapse all years' : 'Expand all years'}
</button>
</div>
<TimelineUpcoming
years={upcomingYears}
count={upcoming.length}
direction={direction}
openYears={openYears}
onToggleYear={toggleYear}
/>
{grouped.map((decade) => (
<TimelineDecade
key={decade.decade}
decade={decade}
openYears={openYears}
onToggleYear={toggleYear}
direction={direction}
fillGaps={fillGaps}
/>
))}
</div>
)
}

View file

@ -0,0 +1,64 @@
import {
decadeLabel,
withGapYears,
type GroupedDecade,
type SortDirection,
} from '../../../lib/timeline'
import TimelineYear from './TimelineYear'
type Props = {
decade: GroupedDecade
openYears: Set<number>
onToggleYear: (year: number) => void
direction?: SortDirection
/** Show years with no records as muted nodes, so gaps stay visible. */
fillGaps?: boolean
}
export default function TimelineDecade({
decade,
openYears,
onToggleYear,
direction = 'desc',
fillGaps = true,
}: Props) {
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
return (
<section
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
>
<header className="ngu-tl__decadeHead">
<span className="ngu-tl__decadeNum" aria-hidden="true">
{decadeLabel(decade.decade)}
</span>
<div>
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
{decade.title}
</h3>
{decade.tagline ? (
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
) : null}
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
{decade.preProgram ? (
<p className="ngu-tl__gapNote">
<span aria-hidden="true">◌</span>
Before the program — kept for context, not part of our record
</p>
) : null}
</div>
</header>
{years.map((year) => (
<TimelineYear
key={year.year}
year={year}
open={openYears.has(year.year)}
onToggle={onToggleYear}
/>
))}
</section>
)
}

View file

@ -0,0 +1,155 @@
import { Link } from 'react-router-dom'
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
milestone: 'Milestone',
event: 'Event',
organization: 'Organization',
award: 'Award',
people: 'People',
}
/** Day number only — the month node above already carries the month. */
function dayMarker(item: TimelineItem): string {
if (item.precision !== 'day') return ''
const day = Number(item.date.split('-')[2])
return Number.isFinite(day) ? String(day) : ''
}
/** Featured entries sit above the month nodes, so they spell out their date. */
function fullMarker(item: TimelineItem): string {
const [, m, d] = item.date.split('-')
if (item.precision === 'year' || !m) return 'Date unrecorded'
const month = MONTH_LABELS[Number(m) - 1] ?? ''
if (item.precision === 'month' || !d) return month
return `${month} ${Number(d)}`
}
/** Initials stand in when a person has no photo on file. */
function initials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0] ?? '')
.join('')
.toUpperCase()
}
function PersonChip({ person }: { person: PersonRef }) {
const src = photoSrc(person.photo)
return (
<li className="ngu-tl__person">
{src ? (
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
) : (
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
{initials(person.name)}
</span>
)}
<span className="ngu-tl__personName">
{person.name}
{person.title ? (
<span className="ngu-tl__personTitle">{person.title}</span>
) : null}
</span>
</li>
)
}
type Props = {
item: TimelineItem
/** Featured entries render larger and lead the year. */
variant?: 'inline' | 'featured'
}
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
const featured = variant === 'featured'
const href = hrefFor(item)
const logo = logoSrc(item)
const className = [
'ngu-tl__entry',
featured ? 'ngu-tl__entry--featured' : '',
href ? 'ngu-tl__entry--linked' : '',
]
.filter(Boolean)
.join(' ')
const marker = (
<span className="ngu-tl__entryDay">
{featured ? fullMarker(item) : dayMarker(item)}
</span>
)
const body = (
<span className="ngu-tl__entryBody">
<span className="ngu-tl__entryTitle">
{logo ? (
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
) : (
<span
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
aria-hidden="true"
/>
)}
{item.title}
</span>
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
</span>
)
// The people list sits outside the link: each name is its own
// destination, and nesting anchors is invalid markup anyway.
const roster =
item.kind === 'people' && item.people && item.people.length > 0 ? (
<div
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
>
{item.team ? (
<span className="ngu-tl__rosterTeam">
{item.team.name}
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
</span>
) : null}
<ul className="ngu-tl__people">
{item.people.map((person) => (
<PersonChip key={person.id} person={person} />
))}
</ul>
</div>
) : null
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
// An explicit href may point off-site; Link is for in-app routes only.
const external = !!href && /^(https?:)?\/\//.test(href)
return (
<li className="ngu-tl__entryWrap">
{href && external ? (
<a
href={href}
className={className}
aria-label={label}
target="_blank"
rel="noopener noreferrer"
>
{marker}
{body}
</a>
) : href ? (
<Link to={href} className={className} aria-label={label}>
{marker}
{body}
</Link>
) : (
<div className={className}>
{marker}
{body}
</div>
)}
{roster}
</li>
)
}

View file

@ -0,0 +1,69 @@
import { useState } from 'react'
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
import TimelineYear from './TimelineYear'
type Props = {
years: GroupedYear[]
count: number
direction?: SortDirection
openYears: Set<number>
onToggleYear: (year: number) => void
}
/**
* Scheduled but not yet happened. Sits above the most recent decade and
* opens upward: the toggle is first in the DOM and the list is rendered
* after it, with `flex-direction: column-reverse` flipping the visual
* order. That keeps the button anchored next to the decade marker
* instead of drifting up the page as items appear.
*
* Membership is decided by date, not by a flag — see partitionByDate.
* An event moves into the record on its own, with nothing to update.
*/
export default function TimelineUpcoming({
years,
count,
direction = 'desc',
openYears,
onToggleYear,
}: Props) {
const [open, setOpen] = useState(false)
if (count === 0) return null
return (
<section
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
aria-label="Upcoming"
>
<button
type="button"
className="ngu-tl__upcomingBtn"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
<span className="ngu-tl__upcomingLabel">
{open
? 'Hide what’s scheduled'
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
</span>
<span className="ngu-tl__chevron" aria-hidden="true">
{open ? '▾' : '▴'}
</span>
</button>
{open ? (
<div className="ngu-tl__upcomingList">
{years.map((year) => (
<TimelineYear
key={year.year}
year={year}
open={openYears.has(year.year)}
onToggle={onToggleYear}
/>
))}
</div>
) : null}
</section>
)
}

View file

@ -0,0 +1,90 @@
import type { CSSProperties } from 'react'
import type { GroupedYear } from '../../../lib/timeline'
import TimelineEntry from './TimelineEntry'
/** Dot diameter scales with volume, so the rail reads as a density chart. */
function dotSize(count: number): string {
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
}
type Props = {
year: GroupedYear
open: boolean
onToggle: (year: number) => void
}
export default function TimelineYear({ year, open, onToggle }: Props) {
const empty = year.count === 0
const panelId = `ngu-tl-year-${year.year}`
const className = [
'ngu-tl__year',
year.featured ? 'ngu-tl__year--featured' : '',
open ? 'ngu-tl__year--open' : '',
empty ? 'ngu-tl__year--empty' : '',
]
.filter(Boolean)
.join(' ')
return (
<div className={className}>
<button
type="button"
className="ngu-tl__yearBtn"
aria-expanded={open}
aria-controls={panelId}
onClick={() => onToggle(year.year)}
>
<span
className="ngu-tl__dot"
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
aria-hidden="true"
/>
<span className="ngu-tl__yearLabel">{year.year}</span>
<span className="ngu-tl__yearCount">
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
</span>
</button>
{open ? (
empty ? (
<div className="ngu-tl__empty" id={panelId}>
Nothing recorded for {year.year} yet.
</div>
) : (
<div className="ngu-tl__panel" id={panelId}>
{year.featuredItems.length > 0 ? (
<ul className="ngu-tl__featured ngu-tl__list">
{year.featuredItems.map((item) => (
<TimelineEntry key={item.id} item={item} variant="featured" />
))}
</ul>
) : null}
{year.months.map((month) => (
<section className="ngu-tl__month" key={month.month}>
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
<ul className="ngu-tl__list">
{month.items.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</ul>
</section>
))}
{year.undated.length > 0 ? (
<section className="ngu-tl__month ngu-tl__undated">
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
<ul className="ngu-tl__list">
{year.undated.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</ul>
</section>
) : null}
</div>
)
) : null}
</div>
)
}

View file

@ -0,0 +1,663 @@
/**
* History timeline — scoped under .ngu-tl.
*
* Everything themeable is a custom property with a fallback, so rewiring to the
* site tokens is a one-block edit at the top. Typefaces are deliberately not
* set: the timeline inherits the page's type and only controls scale, weight
* and tracking.
*/
.ngu-tl {
/* Defaults match the site palette; History.tsx overrides accent and
surface per section so the rail sits on the right background. */
--tl-ink: #16343a;
--tl-muted: #4a6b72;
--tl-faint: #8aa7ad;
--tl-rail: #cfe4e8;
--tl-surface: #ffffff;
--tl-accent: #138ba0;
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
--tl-gutter: 5rem;
--tl-rail-x: 1.75rem;
--tl-rail-w: 2px;
color: var(--tl-ink);
}
@media (max-width: 640px) {
.ngu-tl {
--tl-gutter: 3rem;
--tl-rail-x: 1rem;
}
}
/* ——— Decade block ——————————————————————————————————————————————— */
.ngu-tl__decade {
position: relative;
}
/* The rail. One per decade block so the pre-program run can change style
without breaking the line's continuity. */
.ngu-tl__decade::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: var(--tl-rail-x);
width: var(--tl-rail-w);
background: var(--tl-rail);
}
.ngu-tl__decade--pre::before {
background: none;
border-left: var(--tl-rail-w) dashed var(--tl-rail);
opacity: 0.7;
}
/* Last decade fades out rather than stopping dead. */
.ngu-tl__decade:last-child::before {
bottom: 2rem;
mask-image: linear-gradient(to bottom, #000 60%, transparent);
}
.ngu-tl__decade--pre {
color: var(--tl-muted);
}
/* ——— Decade header ——————————————————————————————————————————————— */
.ngu-tl__decadeHead {
position: relative;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
align-items: start;
padding-block: 3.5rem 1.75rem;
}
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
padding-block-start: 0.5rem;
}
/* Numeral breaks the rail. */
.ngu-tl__decadeNum {
position: relative;
z-index: 1;
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
padding-block: 0.35rem;
background: var(--tl-surface);
font-size: 0.9375rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1.1;
writing-mode: vertical-rl;
text-orientation: sideways;
color: var(--tl-accent);
}
.ngu-tl__decade--pre .ngu-tl__decadeNum {
color: var(--tl-faint);
}
.ngu-tl__decadeTitle {
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
font-weight: 600;
line-height: 1.12;
letter-spacing: -0.018em;
margin: 0;
max-width: 22ch;
}
.ngu-tl__decadeTagline {
margin: 0.5rem 0 0;
font-size: 1.0625rem;
line-height: 1.5;
color: var(--tl-muted);
max-width: 54ch;
}
.ngu-tl__decadeBlurb {
margin: 0.875rem 0 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--tl-muted);
max-width: 62ch;
}
.ngu-tl__gapNote {
margin: 1rem 0 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px dashed var(--tl-rail);
border-radius: 999px;
font-size: 0.8125rem;
color: var(--tl-faint);
}
/* ——— Year node ———————————————————————————————————————————————— */
.ngu-tl__year {
position: relative;
}
.ngu-tl__yearBtn {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
padding-block: 0.6875rem;
background: none;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
border-radius: 0.375rem;
}
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
color: var(--tl-accent);
}
.ngu-tl__yearBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
scrolling the page gives you the organization's density at a glance. */
.ngu-tl__dot {
position: relative;
justify-self: start;
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
width: var(--tl-dot, 8px);
height: var(--tl-dot, 8px);
border-radius: 50%;
background: var(--tl-rail);
box-shadow: 0 0 0 4px var(--tl-surface);
transition: background-color 140ms ease, transform 140ms ease;
}
.ngu-tl__yearBtn:hover .ngu-tl__dot {
background: var(--tl-accent);
}
.ngu-tl__year--featured .ngu-tl__dot {
background: var(--tl-accent);
}
.ngu-tl__year--featured .ngu-tl__dot::after {
content: '';
position: absolute;
inset: -5px;
border: 1.5px solid var(--tl-accent);
border-radius: 50%;
opacity: 0.45;
}
.ngu-tl__year--open .ngu-tl__dot {
background: var(--tl-accent);
transform: scale(1.15);
}
.ngu-tl__decade--pre .ngu-tl__dot {
background: var(--tl-surface);
border: 1.5px solid var(--tl-rail);
}
.ngu-tl__yearLabel {
font-size: 1.125rem;
font-weight: 550;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
transition: color 140ms ease;
}
.ngu-tl__yearCount {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--tl-faint);
padding-right: 0.25rem;
}
.ngu-tl__year--empty .ngu-tl__yearLabel {
color: var(--tl-faint);
font-weight: 450;
}
/* ——— Expanded panel ——————————————————————————————————————————— */
.ngu-tl__panel {
padding: 0.25rem 0 1.5rem var(--tl-gutter);
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
@keyframes ngu-tl-reveal {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.ngu-tl__month {
margin-top: 1.5rem;
}
.ngu-tl__month:first-child {
margin-top: 0.5rem;
}
.ngu-tl__monthLabel {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 0.625rem;
font-size: 0.8125rem;
font-weight: 550;
color: var(--tl-muted);
}
.ngu-tl__monthLabel::after {
content: '';
flex: 1;
height: 1px;
background: var(--tl-rail);
opacity: 0.6;
}
.ngu-tl__undated .ngu-tl__monthLabel {
font-style: italic;
font-weight: 450;
color: var(--tl-faint);
}
.ngu-tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* ——— Entry ————————————————————————————————————————————————————— */
.ngu-tl__entry {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.75rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
}
a.ngu-tl__entry:hover {
background: var(--tl-accent-soft);
}
a.ngu-tl__entry:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 1px;
}
.ngu-tl__entryDay {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--tl-faint);
padding-top: 0.1875rem;
text-align: right;
}
.ngu-tl__entryTitle {
display: block;
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.45;
}
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
color: var(--tl-accent);
}
.ngu-tl__entryMeta {
display: block;
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--tl-muted);
}
.ngu-tl__entryBlurb {
margin: 0.375rem 0 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--tl-muted);
max-width: 64ch;
}
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
calm and the type still carries the hierarchy. */
.ngu-tl__kind {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.5rem;
vertical-align: 0.0625rem;
background: var(--tl-faint);
}
.ngu-tl__kind--milestone {
border-radius: 50%;
background: var(--tl-accent);
}
.ngu-tl__kind--event {
border-radius: 1px;
}
.ngu-tl__kind--award {
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.ngu-tl__kind--person {
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
/* ——— Featured entries ————————————————————————————————————————— */
.ngu-tl__featured {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.ngu-tl__entry--featured {
grid-template-columns: minmax(0, 1fr);
padding: 1rem 1.125rem;
border-left: 2px solid var(--tl-accent);
background: var(--tl-accent-soft);
border-radius: 0 0.5rem 0.5rem 0;
}
.ngu-tl__entry--featured .ngu-tl__entryTitle {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.008em;
}
.ngu-tl__entry--featured .ngu-tl__entryDay {
text-align: left;
padding: 0 0 0.25rem;
}
a.ngu-tl__entry--featured:hover {
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
}
/* ——— Empty state ——————————————————————————————————————————————— */
.ngu-tl__empty {
padding: 0.5rem 0 1.25rem var(--tl-gutter);
font-size: 0.875rem;
color: var(--tl-faint);
}
@media (prefers-reduced-motion: reduce) {
.ngu-tl *,
.ngu-tl *::before,
.ngu-tl *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ——— Toolbar + utilities ————————————————————————————————————— */
.ngu-tl__toolbar {
display: flex;
justify-content: flex-end;
padding-bottom: 1rem;
}
.ngu-tl__toolbarBtn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--tl-rail);
border-radius: 999px;
background: none;
color: var(--tl-muted);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease;
}
.ngu-tl__toolbarBtn:hover {
color: var(--tl-accent);
border-color: var(--tl-accent);
}
.ngu-tl__toolbarBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.ngu-tl__emptyState {
padding: 3rem 0;
color: var(--tl-muted);
font-size: 0.9375rem;
}
.ngu-tl__srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ——— Upcoming block ——————————————————————————————————————————
Opens upward: the toggle is first in the DOM, column-reverse puts the
list above it. Faded because none of it has happened yet. */
.ngu-tl__upcoming {
position: relative;
display: flex;
flex-direction: column-reverse;
padding-bottom: 0.5rem;
}
.ngu-tl__upcoming::before {
content: '';
position: absolute;
top: 1.25rem;
bottom: 0;
left: var(--tl-rail-x);
border-left: var(--tl-rail-w) dashed var(--tl-rail);
}
.ngu-tl__upcomingBtn {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
padding-block: 0.75rem;
background: none;
border: 0;
font: inherit;
color: var(--tl-muted);
text-align: left;
cursor: pointer;
}
.ngu-tl__upcomingBtn:hover {
color: var(--tl-accent);
}
.ngu-tl__upcomingBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
border-radius: 0.375rem;
}
.ngu-tl__upcomingDot {
justify-self: start;
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px dashed var(--tl-accent);
box-shadow: 0 0 0 4px var(--tl-surface);
}
.ngu-tl__upcomingLabel {
font-size: 0.875rem;
}
.ngu-tl__chevron {
font-size: 0.75rem;
padding-right: 0.25rem;
}
/* Everything above the toggle is provisional, and reads that way. */
.ngu-tl__upcomingList {
opacity: 0.62;
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
.ngu-tl__upcomingList:hover,
.ngu-tl__upcomingList:focus-within {
opacity: 1;
}
.ngu-tl__upcomingList .ngu-tl__dot {
background: var(--tl-surface);
border: 1.5px dashed var(--tl-accent);
}
@keyframes ngu-tl-rise {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* ——— Event / org / award logo ————————————————————————————————— */
.ngu-tl__logo {
display: inline-block;
width: 1.125rem;
height: 1.125rem;
margin-right: 0.5rem;
vertical-align: -0.1875rem;
object-fit: contain;
border-radius: 2px;
}
.ngu-tl__entry--featured .ngu-tl__logo {
width: 1.5rem;
height: 1.5rem;
vertical-align: -0.3125rem;
}
/* ——— People roster ——————————————————————————————————————————— */
.ngu-tl__entryWrap {
display: block;
}
.ngu-tl__roster {
padding: 0.125rem 0 0.5rem 3.5rem;
}
.ngu-tl__roster--featured {
padding: 0.5rem 1.125rem 1rem;
border-left: 2px solid var(--tl-accent);
background: var(--tl-accent-soft);
margin-top: -0.5rem;
border-radius: 0 0 0.5rem 0;
}
.ngu-tl__rosterTeam {
display: block;
margin-bottom: 0.5rem;
font-size: 0.75rem;
color: var(--tl-faint);
}
.ngu-tl__people {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 1rem;
list-style: none;
margin: 0;
padding: 0;
}
.ngu-tl__person {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ngu-tl__personPhoto {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
object-fit: cover;
background: var(--tl-accent-soft);
flex: none;
}
.ngu-tl__personPhoto--blank {
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--tl-accent);
}
.ngu-tl__personName {
font-size: 0.8125rem;
line-height: 1.25;
}
.ngu-tl__personTitle {
display: block;
font-size: 0.6875rem;
color: var(--tl-faint);
}
/* ——— Kind markers, continued ————————————————————————————————— */
.ngu-tl__kind--organization {
border-radius: 2px;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
.ngu-tl__kind--people {
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
.ngu-tl__entryBody {
display: block;
min-width: 0;
}

View file

@ -26,6 +26,9 @@ export default defineConfig({
}, },
}, },
}, },
build: {
sourcemap: true,
},
}) })
type SiteConfiguration = { type SiteConfiguration = {