diff --git a/server/src/admin-cli.js b/server/src/admin-cli.js
index 8f540f6..b294a69 100644
--- a/server/src/admin-cli.js
+++ b/server/src/admin-cli.js
@@ -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 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 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 enable them@ngu.org
- add takes --role=viewer for read-only, --name="Full Name".
- Press enter at the password prompt and it generates one and
- prints it once.
+ Roles, low to high. Each one can do everything the one above it
+ in this list can:
- Changing or disabling a password also drops that person's live
- sessions, so "disable" takes effect now rather than in 30 days.
+ viewer read the CMS, change nothing
+ 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 { randomBytes } from "node:crypto";
import { openDatabase, migrate } from "./db.js";
-import { hashPassword, destroyAllSessionsFor } from "./auth.js";
+import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js";
const MIN_PASSWORD = 12;
@@ -86,11 +109,37 @@ function findUser(db, 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) {
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
- const role = flags.role ?? "admin";
- if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
+ const role = checkRole(flags.role ?? "admin");
const password = await readPassword();
@@ -117,10 +166,36 @@ async function passwd(db, email) {
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) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
+ if (!active) warnIfLastSuper(db, user);
+
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
active ? 1 : 0,
user.id,
@@ -147,10 +222,13 @@ function list(db) {
}
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";
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 {
switch (command) {
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);
break;
case "passwd":
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
await passwd(db, email);
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":
+ if (!email) fail("Usage: admin-cli.js disable email@example.com");
setActive(db, email, false);
break;
case "enable":
+ if (!email) fail("Usage: admin-cli.js enable email@example.com");
setActive(db, email, true);
break;
case "list":
list(db);
break;
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);
}
} finally {
diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js
index 9e9b5a8..d0fe17d 100644
--- a/server/src/admin-crud.js
+++ b/server/src/admin-crud.js
@@ -153,7 +153,8 @@ export function listRows(db, entity, query = {}) {
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
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
@@ -161,10 +162,7 @@ export function readRow(db, entity, id) {
if (!row) throw new HttpError(404, "Not found.");
for (const ext of entity.extensions ?? []) {
- row[ext.key] =
- db
- .prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
- .get(id) ?? null;
+ row[ext.key] = readExtension(db, ext, id);
}
for (const child of entity.children ?? []) {
@@ -174,6 +172,29 @@ export function readRow(db, entity, id) {
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) {
const where = [`${child.owner.column} = ?`];
const params = [ownerId];
@@ -208,22 +229,43 @@ function readChildren(db, child, ownerId) {
/* ── Write ───────────────────────────────────────────────────── */
+/* An entity whose id is an autoincrement integer is addressed by a
+ 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) {
- const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
+ // 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 (entity.idKind === "slug" && !SLUG.test(id)) {
- throw new HttpError(422, "Validation failed", {
- [entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
- });
- }
+ if (!auto) {
+ if (entity.idKind === "slug" && !SLUG.test(id)) {
+ throw new HttpError(422, "Validation failed", {
+ [entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
+ });
+ }
- const exists = db
- .prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
- .get(id);
- if (exists) {
- throw new HttpError(422, "Validation failed", {
- [entity.idColumn]: "Already taken.",
- });
+ const exists = db
+ .prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
+ .get(id);
+ if (exists) {
+ throw new HttpError(422, "Validation failed", {
+ [entity.idColumn]: "Already taken.",
+ });
+ }
}
const { values, errors } = coerceRow(entity.columns, payload);
@@ -231,24 +273,43 @@ export function createRow(db, entity, payload) {
throw new HttpError(422, "Validation failed", errors);
}
- const names = [entity.idColumn, ...Object.keys(values)];
+ let newId = id;
wrapDbErrors(() =>
tx(db, () => {
- db.prepare(
- `INSERT INTO ${entity.table} (${names.join(", ")})
- VALUES (${names.map(() => "?").join(", ")})`,
- ).run(id, ...Object.values(values));
+ 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(
+ `INSERT INTO ${entity.table} (${names.join(", ")})
+ VALUES (${names.map(() => "?").join(", ")})`,
+ ).run(id, ...Object.values(values));
+ }
- writeExtensions(db, entity, id, payload, values);
- writeChildren(db, entity, id, payload, values);
+ writeExtensions(db, entity, newId, 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
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
@@ -296,7 +357,8 @@ export function updateRow(db, entity, id, payload) {
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(() =>
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 ?? []) {
if (!applies(ext.when, parentValues)) {
// The gate closed — the kind changed away from this side
- // table, so its row (and anything cascading off it) goes.
- db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
+ // table, or a checkbox was unticked — so its row (and anything
+ // cascading off it) goes.
+ const gone = extensionWhere(ext, id);
+ db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
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);
- 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}`);
+ // 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
- // 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
// conflict clause has to degrade to DO NOTHING or the SQL is
// syntactically invalid.
db.prepare(
`INSERT INTO ${ext.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})
- ON CONFLICT(${ext.idColumn}) ${
+ ON CONFLICT(${conflict.join(", ")}) ${
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.`);
}
+ // 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;
}
}
diff --git a/server/src/admin-schema-sync.js b/server/src/admin-schema-sync.js
index cec6359..f91edc3 100644
--- a/server/src/admin-schema-sync.js
+++ b/server/src/admin-schema-sync.js
@@ -53,7 +53,15 @@ function collectGroups(entity) {
groups.push({
table: ext.table,
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),
});
}
@@ -65,7 +73,7 @@ function collectGroups(entity) {
child.owner.column,
child.owner.kindColumn,
"sort_order",
- ...Object.keys(child.owner.inherit ?? {}),
+ ...Object.keys(child.owner.inherit ?? {}),
].filter(Boolean),
});
for (const nested of child.children ?? []) walk(nested);
diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js
index c77ce46..f1ce448 100644
--- a/server/src/admin-schema.js
+++ b/server/src/admin-schema.js
@@ -74,6 +74,51 @@ const affiliationRole = [
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. */
const linksChild = (ownerKind) => ({
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 ───────────────────────────────────────────── */
const organizations = {
@@ -169,9 +234,11 @@ const organizations = {
...placeColumns,
bool("is_published"),
int("sort_order"),
+ bool("in_timeline"),
],
extensions: [
+ timelineExtension("organization"),
{
key: "region",
table: "regions",
@@ -226,7 +293,7 @@ const events = {
"id",
"title",
"section_id",
- "host_org_id",
+ "event_type",
"date_label",
"starts_on",
"status",
@@ -234,14 +301,29 @@ const events = {
"sort_order",
"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"],
order: "sort_order, starts_on DESC, title",
},
columns: [
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("theme"),
text("tagline"),
@@ -256,9 +338,13 @@ const events = {
text("gradient"),
bool("is_published"),
int("sort_order"),
+ bool("in_timeline"),
],
+ extensions: [timelineExtension("event")],
+
children: [
+ hostsChild,
linksChild("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 ────────────────────── */
@@ -503,6 +651,22 @@ export const OPTION_QUERIES = {
teams:
"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,
// because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate
diff --git a/server/src/auth.js b/server/src/auth.js
index 407517c..4e7f000 100644
--- a/server/src/auth.js
+++ b/server/src/auth.js
@@ -198,10 +198,14 @@ export async function requireAuth(c, next) {
await next();
}
+export const ROLES = ["viewer", "editor", "admin", "superadmin"];
+const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 };
+
export function requireRole(...roles) {
+ const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity));
return async (c, next) => {
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);
}
await next();
diff --git a/server/src/index.js b/server/src/index.js
index a131433..172e523 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -16,9 +16,11 @@ import { openDatabase, migrate } from "./db.js";
import { rateLimit } from "./rateLimit.js";
import content from "./routes/content.js";
import people from "./routes/people.js";
+import history from "./routes/history.js";
import feedback from "./routes/feedback.js";
import auth from "./routes/auth.js";
import admin from "./routes/admin.js";
+import panel from "./routes/panel.js";
import adminEntities from "./routes/admin-entities.js";
import { startSessionSweeper } from "./auth.js";
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
@@ -53,12 +55,14 @@ app.get("/api/health", (c) =>
app.route("/api", content);
app.route("/api", people);
+app.route("/api", history);
// Tighter limit on the write path than anything else gets.
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
app.route("/api/feedback", feedback);
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/admin", admin);
app.route("/api/admin", adminEntities);
diff --git a/server/src/migrations/007_timeline.sql b/server/src/migrations/007_timeline.sql
new file mode 100644
index 0000000..cc097c2
--- /dev/null
+++ b/server/src/migrations/007_timeline.sql
@@ -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;
diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql
new file mode 100644
index 0000000..50869a8
--- /dev/null
+++ b/server/src/migrations/008_timeline_view.sql
@@ -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;
diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql
new file mode 100644
index 0000000..7813579
--- /dev/null
+++ b/server/src/migrations/009_superadmin.sql
@@ -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
diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql
new file mode 100644
index 0000000..8b0175c
--- /dev/null
+++ b/server/src/migrations/010_editor_role.sql
@@ -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
diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql
new file mode 100644
index 0000000..c569400
--- /dev/null
+++ b/server/src/migrations/011_award_published.sql
@@ -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
diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql
new file mode 100644
index 0000000..30cef0d
--- /dev/null
+++ b/server/src/migrations/012_event_hosts.sql
@@ -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
diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql
new file mode 100644
index 0000000..08c3831
--- /dev/null
+++ b/server/src/migrations/013_drop_host_org_id.sql
@@ -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
diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql
new file mode 100644
index 0000000..fec43bf
--- /dev/null
+++ b/server/src/migrations/014_event-type.sql
@@ -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);
diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql
new file mode 100644
index 0000000..113b0b5
--- /dev/null
+++ b/server/src/migrations/015_event-scopes.sql
@@ -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);
diff --git a/server/src/routes/admin-entities.js b/server/src/routes/admin-entities.js
index 183863e..98a7b66 100644
--- a/server/src/routes/admin-entities.js
+++ b/server/src/routes/admin-entities.js
@@ -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);
});
-entities.post("/:entity", requireRole("admin"), async (c) => {
+entities.post("/:entity", requireRole("editor"), async (c) => {
const entity = entityOr404(c);
const row = createRow(c.get("db"), entity, await json(c));
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
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 id = c.req.param("id");
const row = updateRow(c.get("db"), entity, id, await json(c));
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index fb88550..2bcd7c0 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -93,7 +93,7 @@ admin.get("/feedback", (c) => {
{ 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"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
diff --git a/server/src/routes/content.js b/server/src/routes/content.js
index d77c2c6..87aab06 100644
--- a/server/src/routes/content.js
+++ b/server/src/routes/content.js
@@ -5,6 +5,10 @@
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
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
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.
Responses carry their fallbacks already resolved: an event's
- `color` is its own or its host's, and `status` is derived from
- the dates when it isn't set. Components read one field and don't
- reimplement the rules.
+ `color` is its own or its first host's, and `status` is derived
+ from the dates when it isn't set. Components read one field and
+ 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";
@@ -34,14 +62,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"];
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 ───────────────────────────────────────────────── */
-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);
return {
id: row.id,
section_id: row.section_id,
+ event_type: row.event_type,
title: row.title,
theme: row.theme,
@@ -63,9 +136,7 @@ function shapeEvent(row, links, cardBlocks) {
color: row.effective_color,
gradient: row.gradient,
- host: row.host_org_id
- ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
- : null,
+ hosts: hosts.map(shapeHost),
description: paragraphs(cardBlocks),
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 ────────────────────────────
Each of these runs a fixed number of queries for the whole list
rather than one per organization.
@@ -243,6 +350,84 @@ function attachLeadership(db, orgs) {
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 ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the
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 links = loadLinks(db, "event", ids);
const cards = loadBlocks(db, "event", ids, "card");
+ const hosts = loadHosts(db, ids);
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 });
@@ -289,6 +480,7 @@ content.get("/events/:id", (c) => {
const links = loadLinks(db, "event", [id]).get(id) ?? [];
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
+ const hosts = loadHosts(db, [id]).get(id) ?? [];
const people = db
.prepare(
@@ -297,8 +489,36 @@ content.get("/events/:id", (c) => {
)
.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, {
- 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);
attachChapterDetails(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
.prepare(
- `SELECT id, title, date_label, effective_status AS status,
- location_label, event_logo, effective_color AS color
- FROM v_events
- WHERE host_org_id = ? AND is_published = 1
- ORDER BY sort_order`,
+ `SELECT e.id, e.title, e.date_label, e.event_type,
+ e.effective_status AS status,
+ e.location_label, e.event_logo,
+ e.effective_color AS color
+ 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);
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;
diff --git a/server/src/routes/history.js b/server/src/routes/history.js
new file mode 100644
index 0000000..e7beca1
--- /dev/null
+++ b/server/src/routes/history.js
@@ -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;
diff --git a/server/src/routes/panel.js b/server/src/routes/panel.js
new file mode 100644
index 0000000..394da02
--- /dev/null
+++ b/server/src/routes/panel.js
@@ -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;
diff --git a/src/App.tsx b/src/App.tsx
index 49b3e10..9b7efd4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import Layout from "./components/Layout.tsx";
/*Libraries*/
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
+import RequireRole from "./pages/admin/RequireRole.tsx";
/*Primary Pages*/
import Home from "./pages/Home.tsx";
@@ -11,14 +12,23 @@ import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx";
+import History from "./pages/History.tsx";
/*Secondary Pages*/
import Feedback from "./pages/Feedback.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*/
import AdminLayout from "./pages/admin/AdminLayout.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 EntityList from "./pages/admin/EntityList.tsx";
import EntityEdit from "./pages/admin/EntityEdit.tsx";
@@ -36,10 +46,18 @@ export default function App() {
}>
} />
} />
+ } />
} />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
} />
- } />
+ } />
+ } />
} />
} />
} />
@@ -49,7 +67,11 @@ export default function App() {
} />
}>
}>
- } />
+ } />
+ } />
+ }>
+ } />
+
} />
} />
} />
diff --git a/src/components/ContentBlocks.tsx b/src/components/ContentBlocks.tsx
new file mode 100644
index 0000000..fab186c
--- /dev/null
+++ b/src/components/ContentBlocks.tsx
@@ -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 (
+
+ {children}
+
+ )
+ }
+ return (
+
+ {children}
+
+ )
+}
+
+export default function ContentBlocks({
+ blocks,
+ accent = '#138ba0',
+ className = '',
+}: {
+ blocks?: ContentBlock[] | null
+ accent?: string
+ className?: string
+}) {
+ if (!blocks?.length) return null
+
+ return (
+
+ )
+ }
+}
diff --git a/src/components/PageState.tsx b/src/components/PageState.tsx
new file mode 100644
index 0000000..4c5d5b8
--- /dev/null
+++ b/src/components/PageState.tsx
@@ -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 = (
+
+
+ There’s no {noun} at this address. It may have been renamed, or taken
+ down.
+
+
+ {backLabel}
+
+
+ )
+ } else if (error) {
+ title = 'Something went wrong'
+ content = (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx
index ccf1be0..95296f8 100644
--- a/src/components/admin/fields.tsx
+++ b/src/components/admin/fields.tsx
@@ -57,6 +57,9 @@ const inputLocked =
/* ── Dotted paths ────────────────────────────────────────────── */
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);
}
diff --git a/src/data/eventData.js b/src/data/eventData.js
index ee2364e..648d7ff 100644
--- a/src/data/eventData.js
+++ b/src/data/eventData.js
@@ -7,10 +7,17 @@
narrows the result to what it shows.
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({ type: "workshop" }) one kind, wherever it is
+ useEvents({ type: ["class", "workshop"] })
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
one cached response. At a few dozen events that's the right
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: [] };
-export function useEvents({ section, host, status } = {}) {
+export function useEvents({ section, host, status, type } = {}) {
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
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(() => {
let list = all ?? [];
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 (typeKey) {
+ const wanted = new Set(typeKey.split(","));
+ list = list.filter(e => wanted.has(e.event_type));
+ }
return list;
- }, [all, section, host, status]);
+ }, [all, section, host, status, typeKey]);
return { events, loading, error };
}
@@ -52,3 +71,12 @@ export function splitByStatus(events = []) {
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));
+}
diff --git a/src/data/historyDecades.ts b/src/data/historyDecades.ts
new file mode 100644
index 0000000..4d1acaa
--- /dev/null
+++ b/src/data/historyDecades.ts
@@ -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',
+ },
+]
diff --git a/src/data/old-historyMock.ts b/src/data/old-historyMock.ts
new file mode 100644
index 0000000..82b7855
--- /dev/null
+++ b/src/data/old-historyMock.ts
@@ -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.',
+ },
+]
diff --git a/src/data/organizations.js b/src/data/organizations.js
index 50a5405..6aeeec7 100644
--- a/src/data/organizations.js
+++ b/src/data/organizations.js
@@ -32,7 +32,7 @@ export function useOrganizations(kind) {
? `/organizations?kind=${encodeURIComponent(kind)}`
: "/organizations";
- const { data, error, loading } = useResource(path, { fallback: EMPTY });
+ const { data, error, loading } = useResource(path);
return {
organizations: data?.organizations ?? [],
diff --git a/src/lib/adminSchema.js b/src/lib/adminSchema.js
index 61f15ea..3c3b042 100644
--- a/src/lib/adminSchema.js
+++ b/src/lib/adminSchema.js
@@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [
{ 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. */
const linksChild = {
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 = {
key: "content_blocks",
label: "Content blocks",
@@ -184,6 +293,8 @@ const organizations = {
],
},
{ legend: "Place", fields: PLACE_FIELDS },
+ timelineGroup("organization"),
+ timelineDetailGroup("organization"),
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
@@ -225,13 +336,19 @@ const events = {
list: {
columns: [
{ 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: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" },
],
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: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
@@ -241,20 +358,25 @@ const events = {
{
legend: "Identity",
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",
- label: "Section",
+ label: "Scope",
widget: "select",
optionsFrom: "event_sections",
required: true,
+ help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
},
{
- path: "host_org_id",
- label: "Host",
+ path: "event_type",
+ label: "Type",
widget: "select",
- optionsFrom: "organizations",
- blankLabel: "— none —",
- help: "Supplies the logo and colour when this event sets neither",
+ options: ["retreat", "class", "workshop", "meeting", "other"],
+ required: true,
+ help: "What kind of gathering. Independent of the scope",
},
{ path: "title", label: "Title", required: true },
{ path: "theme", label: "Theme" },
@@ -290,10 +412,13 @@ const events = {
{ path: "gradient", label: "Gradient", full: true },
],
},
+ timelineGroup("event"),
+ timelineDetailGroup("event"),
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
+ hostsChild,
{
key: "event_people",
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) {
return String(value ?? "")
diff --git a/src/lib/eventTypes.ts b/src/lib/eventTypes.ts
new file mode 100644
index 0000000..fbc0d96
--- /dev/null
+++ b/src/lib/eventTypes.ts
@@ -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(
+ 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 ?? '')
diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts
new file mode 100644
index 0000000..1b27845
--- /dev/null
+++ b/src/lib/hrefs.ts
@@ -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 = {
+ 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 = {
+ 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' }
+ }
+}
diff --git a/src/lib/media.ts b/src/lib/media.ts
new file mode 100644
index 0000000..5f683c4
--- /dev/null
+++ b/src/lib/media.ts
@@ -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 | 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 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()
+}
diff --git a/src/lib/roles.ts b/src/lib/roles.ts
new file mode 100644
index 0000000..2150282
--- /dev/null
+++ b/src/lib/roles.ts
@@ -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 = {
+ viewer: 1,
+ editor: 2,
+ admin: 3,
+ superadmin: 4,
+};
+
+export const ROLE_LABELS: Record = {
+ 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 = {
+ 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");
diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts
new file mode 100644
index 0000000..29d02e9
--- /dev/null
+++ b/src/lib/timeline.ts
@@ -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()
+ 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()
+
+ 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()
+ 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 []
+}
diff --git a/src/lib/timelineRefs.ts b/src/lib/timelineRefs.ts
new file mode 100644
index 0000000..a868f8a
--- /dev/null
+++ b/src/lib/timelineRefs.ts
@@ -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 instead of a , 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
+
+ {STATUS_LABEL[event.status] ?? event.status}
+
+
+ {/* 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 && (
+
+ {eventTypeLabel(event.event_type)}
+
+ )}
+
+ {when && {when}}
+ {where && {where}}
+ {event.is_online && where !== 'Online' && (
+ Online too
+ )}
+
+ {(event.hosts?.length ?? 0) > 0 && (
+
+ Hosted by
+
+ )}
+
+
+ All retreats
+
+
+ )
+}
+
+/* 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 (
+
+ {index > 0 && (hosts.length > 2 ? ', ' : ' ')}
+ {index > 0 && index === hosts.length - 1 && 'and '}
+ {to ? (
+
+ {host.name}
+
+ ) : (
+ {host.name}
+ )}
+
+ )
+ })}
+ >
+ )
+}
+
+/* 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()
+ 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)
diff --git a/src/pages/History.tsx b/src/pages/History.tsx
new file mode 100644
index 0000000..610e791
--- /dev/null
+++ b/src/pages/History.tsx
@@ -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) => (
+
+ {children}
+
+ );
+
+ let content: React.ReactNode;
+
+ if (loading) {
+ content = wrap(
+
+ Loading the timeline…
+
,
+ );
+ } 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(
+
+
Couldn’t load the timeline. {error}
+
+
,
+ );
+ } else {
+ content = wrap(
+ ,
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/pages/OrganizationDetail.tsx b/src/pages/OrganizationDetail.tsx
new file mode 100644
index 0000000..ec9977a
--- /dev/null
+++ b/src/pages/OrganizationDetail.tsx
@@ -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 (
+
+ )
+ }
+
+ // /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
+ }
+
+ const accent = org.color || TEAL
+ const back = orgListHref(org.kind)
+
+ const sections: any[] = [
+ {
+ id: 'about',
+ title: 'About',
+ accent,
+ background: '#ffffff',
+ content: (
+
+ ),
+ })
+ }
+
+ // 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: (
+
+ ),
+ })
+ }
+
+ return
+}
+
+/* ── 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 (
+
+ )
+}
+
+function Logo({
+ file,
+ name,
+ accent,
+}: {
+ file?: string | null
+ name: string
+ accent: string
+}) {
+ const src = orgLogo(file)
+ if (!src) {
+ return (
+
+ {name.slice(0, 2).toUpperCase()}
+
+ )
+ }
+ return
+}
+
+/* ── 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()
+ 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
+}
diff --git a/src/pages/Retreats.tsx b/src/pages/Retreats.tsx
index 7e283d7..17d3bbd 100644
--- a/src/pages/Retreats.tsx
+++ b/src/pages/Retreats.tsx
@@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
different filter. The page declares them and does nothing else —
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
section, add an entry with another Component.
═══════════════════════════════════════════════════════════════ */
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 = [
{
id: "national",
@@ -23,7 +45,7 @@ const SECTIONS = [
accent: "#138ba0",
background: "#eef9fb",
Component: EventListCards,
- props: { section: "national" },
+ props: { section: "national", ...RETREATS },
views: { ...CARD_VIEWS, default: "carousel" },
},
{
@@ -33,19 +55,56 @@ const SECTIONS = [
accent: "#aac992",
background: "#ffffff",
Component: EventListCards,
- props: { section: "regional" },
+ props: { section: "regional", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" },
},
{
id: "partner",
title: "Partner Events",
- blurb: "Events hosted by organizations we collaborate with.",
+ blurb: "Retreats hosted by organizations we collaborate with.",
accent: "#7a5ea8",
background: "#eef9fb",
Component: EventListCards,
- props: { section: "partner" },
+ props: { section: "partner", ...RETREATS },
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() {
@@ -54,7 +113,7 @@ export default function RetreatsPage() {
return (
);
diff --git a/src/pages/TeamDetail.tsx b/src/pages/TeamDetail.tsx
new file mode 100644
index 0000000..304a0c7
--- /dev/null
+++ b/src/pages/TeamDetail.tsx
@@ -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 (
+
+ )
+ }
+
+ 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: (
+
+
+ A team of{' '}
+
+ {orgName}
+
+
+
+
+ Back to {orgName}
+
+
+ )
+}
diff --git a/src/pages/admin/AdminFeedback.tsx b/src/pages/admin/AdminFeedback.tsx
index 982b232..dbfbb3d 100644
--- a/src/pages/admin/AdminFeedback.tsx
+++ b/src/pages/admin/AdminFeedback.tsx
@@ -2,8 +2,15 @@
ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through
- PATCH. Deliberately a flat list rather than a table: the message
- is the content, and messages don't fit in a cell.
+ PATCH, and deletes through DELETE. Deliberately a flat list
+ 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
content that changes weekly; a triage queue two people are
@@ -13,8 +20,9 @@
import { useCallback, useEffect, useState } from "react";
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 { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@@ -44,10 +52,11 @@ function locationOf(row) {
/* ── One submission ──────────────────────────────────────────── */
-function FeedbackCard({ row, onChange, canWrite }) {
+function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ const [confirming, setConfirming] = useState(false);
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 (
);
}
@@ -170,7 +238,9 @@ export default function AdminFeedback() {
const [loading, setLoading] = useState(true);
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(
async (before = null) => {
@@ -218,6 +288,16 @@ export default function AdminFeedback() {
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 = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
@@ -294,7 +374,9 @@ export default function AdminFeedback() {
key={row.id}
row={row}
canWrite={canWrite}
+ canRemove={canRemove}
onChange={replaceRow}
+ onRemove={removeRow}
/>
))}
diff --git a/src/pages/admin/AdminHome.tsx b/src/pages/admin/AdminHome.tsx
new file mode 100644
index 0000000..0d17675
--- /dev/null
+++ b/src/pages/admin/AdminHome.tsx
@@ -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 (
+
+
+ {/* Sticky so it stays put once the card column outgrows it. */}
+
+
+ );
+}
diff --git a/src/pages/admin/AdminLayout.tsx b/src/pages/admin/AdminLayout.tsx
index 014d960..ab073ec 100644
--- a/src/pages/admin/AdminLayout.tsx
+++ b/src/pages/admin/AdminLayout.tsx
@@ -5,59 +5,45 @@
footer site map — none of that belongs around a staff tool, and
/admin should never appear in navConfig.
- The nav is two tiers, the same shape as the public header: a
- primary row of the things you'd go looking for, and a subnav of
- whatever sits under the one you're in. Teams and Awards live
- under Organizations because that's where they belong
- conceptually — a team is part of an org, an award is given by
- one — even though each is its own table and its own page.
+ Three areas share this chrome: Home, the CMS and the Panel. The
+ wordmark names whichever one you're in, and from anywhere but
+ Home it's the way back to Home.
- NAV is the single source for the rows, the document title and
- which tab lights up. Adding an entity is an entry here plus a
- descriptor; there's no second list to keep in step.
+ The tab row is drawn everywhere except Home, where the card grid
+ is the navigation and drawing both would say the same thing
+ twice. Which tabs appear depends on the signed-in user —
+ navFor() drops the superadmin-only ones — but that's cosmetics.
+ The route guard and the API are what actually say no.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react";
-import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
+import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
-
-const SITE_TITLE = "NGU Admin CMS";
-
-// A group with no `to` of its own opens its first child, so
-// clicking the word Forms goes somewhere rather than nowhere.
-const NAV = [
- { to: "/admin/events", label: "Events" },
- {
- to: "/admin/organizations",
- label: "Organizations",
- children: [
- { to: "/admin/organizations", label: "All organizations" },
- { to: "/admin/teams", label: "Teams" },
- { to: "/admin/awards", label: "Awards" },
- ],
- },
- { to: "/admin/people", label: "People" },
- {
- label: "Forms",
- separated: true,
- children: [{ to: "/admin/feedback", label: "Website feedback" }],
- },
-];
-
-// A tab owns its own page and everything below it, so editing
-// /admin/teams/ngu-board keeps Teams lit.
-const matches = (pathname, to) =>
- Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
-
-const target = (item) => item.to ?? item.children?.[0]?.to;
+import { SITE_VERSION } from "../../lib/version.ts";
+import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
+import nguLogo from "../../assets/NGU_Logo.svg";
+import {
+ ADMIN_HOME,
+ AREA_TITLES,
+ areaFor,
+ matches,
+ navFor,
+ target,
+} from "./adminNav.js";
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
- const active = NAV.find(
+ const area = areaFor(pathname);
+ const areaTitle = AREA_TITLES[area];
+ const isHome = area === "home";
+
+ const nav = useMemo(() => navFor(user), [user]);
+
+ const active = nav.find(
(item) =>
matches(pathname, item.to) ||
(item.children ?? []).some((child) => matches(pathname, child.to)),
@@ -75,9 +61,12 @@ export default function AdminLayout() {
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {
- const section = activeChild?.label ?? active?.label;
- document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
- }, [active, activeChild, detail]);
+ // Sections only exist in the CMS. Home and Panel already say
+ // what they are in the area title; "Panel | NGU Admin Panel"
+ // would just stutter.
+ const section = area === "cms" ? activeChild?.label ?? active?.label : null;
+ document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
+ }, [area, areaTitle, active, activeChild, detail]);
async function handleLogout() {
await logout();
@@ -86,37 +75,54 @@ export default function AdminLayout() {
const subnav = active?.children ?? [];
+ const wordmark = {areaTitle};
+
return (
-
+
- {SITE_TITLE}
+ {/* Already home, so nothing to link to. */}
+ {isHome ? (
+ wordmark
+ ) : (
+
+ {wordmark}
+
+ )}
-
+ {!isHome && (
+
+ )}
);
}
diff --git a/src/pages/admin/AdminLogin.tsx b/src/pages/admin/AdminLogin.tsx
index 019c521..e558dda 100644
--- a/src/pages/admin/AdminLogin.tsx
+++ b/src/pages/admin/AdminLogin.tsx
@@ -32,7 +32,7 @@ export default function AdminLogin() {
const [error, setError] = useState(null);
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) {
event.preventDefault();
diff --git a/src/pages/admin/AdminPanel.tsx b/src/pages/admin/AdminPanel.tsx
new file mode 100644
index 0000000..5a9e22c
--- /dev/null
+++ b/src/pages/admin/AdminPanel.tsx
@@ -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 (
+
+
+
+ {/* Each rung adds to the one above it. Worth stating, because
+ "Editor" doesn't tell you where the line falls. */}
+
+ {ROLES.map((role) => (
+
+
+ {ROLE_LABELS[role]}
+
+
{ROLE_NOTES[role]}
+
+ ))}
+
+
+
+ New accounts are still created with admin-cli.js on the server.
+
+
+
+ );
+}
diff --git a/src/pages/admin/EntityEdit.tsx b/src/pages/admin/EntityEdit.tsx
index fac6a10..6be500c 100644
--- a/src/pages/admin/EntityEdit.tsx
+++ b/src/pages/admin/EntityEdit.tsx
@@ -23,6 +23,16 @@
updated_at column simply never get one, and the 409 path stays
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
because the name is: two organizations aren't both called
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 { useAdminDetail } from "../../lib/adminTitle.tsx";
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";
/* 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 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
// 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
- // page reads "Board", not "northwest Board".
- const headingPath = slugPaths[slugPaths.length - 1];
+ // page reads "Board", not "northwest Board". An entity with no slug
+ // names the field to read instead.
+ const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
@@ -120,7 +139,9 @@ export default function EntityEdit() {
// touch", which is wrong for a row that doesn't exist yet.
// The parent's own fields stay absent on purpose so the
// server's column defaults apply to whatever isn't filled in.
- const blank = { id: "" };
+ // 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] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
@@ -139,7 +160,7 @@ export default function EntityEdit() {
} finally {
setLoading(false);
}
- }, [manifest, id, isNew, navigate]);
+ }, [manifest, id, isNew, autoId, navigate]);
useEffect(() => {
load();
@@ -288,7 +309,19 @@ export default function EntityEdit() {
{isNew ? `New ${manifest.singular}` : heading}
- {/* 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 && (
+
+ )}
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
@@ -332,6 +366,7 @@ export default function EntityEdit() {
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
- {!isNew && (
+ {!isNew && canDelete && (
) : (
- Read-only: your account can't save changes.
+ Read-only: your account can view this but not change it.
)}
diff --git a/src/pages/admin/EntityList.tsx b/src/pages/admin/EntityList.tsx
index e227b3f..b0b4197 100644
--- a/src/pages/admin/EntityList.tsx
+++ b/src/pages/admin/EntityList.tsx
@@ -4,6 +4,12 @@
One component for organizations, events and people. The :entity
route param picks the manifest; nothing here knows what a
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";
@@ -12,6 +18,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
+import { atLeast } from "../../lib/roles.ts";
export default function EntityList() {
const { entity: entityKey } = useParams();
@@ -26,7 +33,12 @@ export default function EntityList() {
const [error, setError] = useState(null);
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 () => {
if (!manifest) return;
diff --git a/src/pages/admin/RequireRole.tsx b/src/pages/admin/RequireRole.tsx
new file mode 100644
index 0000000..ef0395e
--- /dev/null
+++ b/src/pages/admin/RequireRole.tsx
@@ -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 ;
+ if (!atLeast(user, role)) return ;
+
+ return ;
+}
diff --git a/src/pages/admin/adminNav.js b/src/pages/admin/adminNav.js
new file mode 100644
index 0000000..d454ea2
--- /dev/null
+++ b/src/pages/admin/adminNav.js
@@ -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";
+}
diff --git a/src/pages/sections/EventList-Cards.tsx b/src/pages/sections/EventList-Cards.tsx
index 4470f76..d797bab 100644
--- a/src/pages/sections/EventList-Cards.tsx
+++ b/src/pages/sections/EventList-Cards.tsx
@@ -1,5 +1,8 @@
-import { useEffect, useRef, useState } from "react";
-import { splitByStatus, useEvents } from "../../data/eventData.js";
+import { useEffect, useMemo, useRef, useState } from "react";
+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
@@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
+
`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", {
@@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
);
+/* 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 (
+
+ {children}
+
+ );
+}
+
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
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
own or its host's, `status` is derived from the dates when it
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({
ev,
@@ -126,6 +155,8 @@ export function Card({
accent = TEAL,
compact = false,
interactive = true,
+ linked = true,
+ showType = false,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
@@ -137,6 +168,19 @@ export function Card({
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: 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 ? (
+
+ {eventTypeLabel(ev.event_type)}
+
+ ) : null;
+
/* An ordered array, so a card can carry one paragraph or five
without the component changing. */
const descriptions = (ev.description ?? []).map((text, i) => (
@@ -168,7 +212,12 @@ export function Card({
-
{ev.title}
+ {typeBadge}
+
+
+ {ev.title}
+
+
{ev.theme && (
"{ev.theme}"
)}
@@ -190,7 +239,12 @@ export function Card({
-
{ev.title}
+ {typeBadge}
+
+
+ {ev.title}
+
+
{ev.theme && (
"{ev.theme}"
)}
@@ -211,10 +265,32 @@ export function Card({
>
)}
- {/* Footer — mt-auto pins it to the bottom so buttons line up
- across every card in a grid row. */}
- {links.length > 0 ? (
-
+ {/* Footer — mt-auto pins the whole block to the bottom so
+ buttons line up across every card in a grid row.
+
+ 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. */}
+
+ {links.length === 0 && (
+
+ {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."}
+
- 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."}
-
- {igHandle && (
+
+ {/* Only when there's nothing to register for and the
+ event hasn't happened — the same condition as before,
+ just no longer nested inside that branch. */}
+ {igHandle && !past && links.length === 0 && (
@@ -256,13 +321,70 @@ export function Card({
{igHandle}
)}
+
+ {/* Last, so Register reads first when there is one. */}
+ {linked && (
+
+ Event details
+
+ )}
- )}
+
);
}
+/* ═══════════════════════════════════════════════════════════════
+ 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) => (
+
+ );
+
+ return (
+
+ );
+}
+
/* ═══════════════════════════════════════════════════════════════
TOGGLE — the control for the section heading's action bar
═══════════════════════════════════════════════════════════════ */
@@ -323,22 +445,57 @@ export default function EventListCards({
section,
host,
status,
+ type,
view = "carousel",
accent = TEAL,
defaultColor,
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 [index, setIndex] = useState(0);
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
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
the list empties means a refetch re-seeds. */
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(() => {
if (events.length === 0) {
seeded.current = false;
@@ -395,8 +552,24 @@ export default function EventListCards({
return (
+ {mixed && (
+
+ )}
+
{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" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
: null}
+
+ )
+
+ // 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 ? (
+
+ )
+}
diff --git a/src/pages/sections/history/TimelineUpcoming.tsx b/src/pages/sections/history/TimelineUpcoming.tsx
new file mode 100644
index 0000000..12041cf
--- /dev/null
+++ b/src/pages/sections/history/TimelineUpcoming.tsx
@@ -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
+ 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 (
+
+
+
+ {open ? (
+