v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
29d80b74df
63 changed files with 7927 additions and 208 deletions
|
|
@ -9,22 +9,45 @@
|
||||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
|
||||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
|
||||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js role them@ngu.org editor
|
||||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
|
||||||
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
|
||||||
|
|
||||||
add takes --role=viewer for read-only, --name="Full Name".
|
Roles, low to high. Each one can do everything the one above it
|
||||||
Press enter at the password prompt and it generates one and
|
in this list can:
|
||||||
prints it once.
|
|
||||||
|
|
||||||
Changing or disabling a password also drops that person's live
|
viewer read the CMS, change nothing
|
||||||
sessions, so "disable" takes effect now rather than in 30 days.
|
editor + create and update records
|
||||||
|
admin + delete records
|
||||||
|
superadmin + accounts, roles and sessions, via /admin/panel
|
||||||
|
|
||||||
|
add takes --role=editor, --name="Full Name". It defaults to
|
||||||
|
admin. Press enter at the password prompt and it generates one
|
||||||
|
and prints it once.
|
||||||
|
|
||||||
|
The list comes from auth.js rather than being repeated here, so
|
||||||
|
the CLI can't drift from what requireRole will actually accept.
|
||||||
|
|
||||||
|
This is also how the first superadmin is made — there's no
|
||||||
|
bootstrap path in the web interface, on purpose:
|
||||||
|
|
||||||
|
node src/admin-cli.js role you@ngu.org superadmin
|
||||||
|
|
||||||
|
Changing a password, a role, or disabling an account drops that
|
||||||
|
person's live sessions, so it takes effect now rather than in
|
||||||
|
30 days.
|
||||||
|
|
||||||
|
Nothing here refuses to demote or disable the last superadmin.
|
||||||
|
The panel does, because a misclick there locks everyone out;
|
||||||
|
here you're already root on the box holding the database, and a
|
||||||
|
recovery tool that argues with you isn't one.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
import { openDatabase, migrate } from "./db.js";
|
import { openDatabase, migrate } from "./db.js";
|
||||||
import { hashPassword, destroyAllSessionsFor } from "./auth.js";
|
import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js";
|
||||||
|
|
||||||
const MIN_PASSWORD = 12;
|
const MIN_PASSWORD = 12;
|
||||||
|
|
||||||
|
|
@ -86,11 +109,37 @@ function findUser(db, email) {
|
||||||
.get(email);
|
.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkRole(role) {
|
||||||
|
if (!ROLES.includes(role)) {
|
||||||
|
fail(`Role must be one of: ${ROLES.join(", ")}.`);
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printed, never enforced — see the note at the top of the file.
|
||||||
|
Worth saying out loud, because the person doing it is usually
|
||||||
|
tidying up accounts rather than thinking about lockouts. */
|
||||||
|
function warnIfLastSuper(db, user) {
|
||||||
|
if (user.role !== "superadmin" || user.is_active !== 1) return;
|
||||||
|
|
||||||
|
const { n } = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (n <= 1) {
|
||||||
|
console.warn(
|
||||||
|
"⚠ That's the last active superadmin. Nobody will be able to manage\n" +
|
||||||
|
" accounts from /admin/panel until you promote someone here.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function add(db, email, flags) {
|
async function add(db, email, flags) {
|
||||||
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
|
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
|
||||||
|
|
||||||
const role = flags.role ?? "admin";
|
const role = checkRole(flags.role ?? "admin");
|
||||||
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
|
|
||||||
|
|
||||||
const password = await readPassword();
|
const password = await readPassword();
|
||||||
|
|
||||||
|
|
@ -117,10 +166,36 @@ async function passwd(db, email) {
|
||||||
console.log(`✓ password changed for ${email}, existing sessions ended`);
|
console.log(`✓ password changed for ${email}, existing sessions ended`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRole(db, email, role) {
|
||||||
|
const user = findUser(db, email);
|
||||||
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
checkRole(role);
|
||||||
|
|
||||||
|
if (user.role === role) {
|
||||||
|
console.log(`· ${email} is already ${role}, nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a demotion can strand the account list.
|
||||||
|
if (role !== "superadmin") warnIfLastSuper(db, user);
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET role = ? WHERE id = ?").run(role, user.id);
|
||||||
|
|
||||||
|
// The session they're holding was issued against the old role.
|
||||||
|
// Every check reads the row fresh, so it isn't a security hole —
|
||||||
|
// but their open tab would keep drawing buttons that now 403.
|
||||||
|
destroyAllSessionsFor(db, user.id);
|
||||||
|
|
||||||
|
console.log(`✓ ${email} is now ${role} (was ${user.role}), sessions ended`);
|
||||||
|
}
|
||||||
|
|
||||||
function setActive(db, email, active) {
|
function setActive(db, email, active) {
|
||||||
const user = findUser(db, email);
|
const user = findUser(db, email);
|
||||||
if (!user) fail(`No account for ${email}.`);
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
if (!active) warnIfLastSuper(db, user);
|
||||||
|
|
||||||
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
|
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
|
||||||
active ? 1 : 0,
|
active ? 1 : 0,
|
||||||
user.id,
|
user.id,
|
||||||
|
|
@ -147,10 +222,13 @@ function list(db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const state = r.is_active ? r.role : "disabled";
|
// Role and state are separate facts now. The old single column
|
||||||
|
// printed "disabled" over the top of the role, which hid what
|
||||||
|
// the account would go back to on enable.
|
||||||
|
const state = r.is_active ? r.role : `${r.role} (disabled)`;
|
||||||
const seen = r.last_login_at ?? "never";
|
const seen = r.last_login_at ?? "never";
|
||||||
console.log(
|
console.log(
|
||||||
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
|
`${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,24 +253,38 @@ migrate(db); // so a fresh database gets the tables before we use them
|
||||||
try {
|
try {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "add":
|
case "add":
|
||||||
if (!email) fail("Usage: admin-cli.js add email@example.com");
|
if (!email) fail("Usage: admin-cli.js add email@example.com [--role=editor]");
|
||||||
await add(db, email, flags);
|
await add(db, email, flags);
|
||||||
break;
|
break;
|
||||||
case "passwd":
|
case "passwd":
|
||||||
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
|
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
|
||||||
await passwd(db, email);
|
await passwd(db, email);
|
||||||
break;
|
break;
|
||||||
|
case "role": {
|
||||||
|
// Positional reads better for a two-argument command, but
|
||||||
|
// --role= is what `add` takes, so accept both rather than
|
||||||
|
// making people remember which is which.
|
||||||
|
const role = positional[1]?.trim().toLowerCase() ?? flags.role;
|
||||||
|
if (!email || !role) {
|
||||||
|
fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`);
|
||||||
|
}
|
||||||
|
setRole(db, email, role);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "disable":
|
case "disable":
|
||||||
|
if (!email) fail("Usage: admin-cli.js disable email@example.com");
|
||||||
setActive(db, email, false);
|
setActive(db, email, false);
|
||||||
break;
|
break;
|
||||||
case "enable":
|
case "enable":
|
||||||
|
if (!email) fail("Usage: admin-cli.js enable email@example.com");
|
||||||
setActive(db, email, true);
|
setActive(db, email, true);
|
||||||
break;
|
break;
|
||||||
case "list":
|
case "list":
|
||||||
list(db);
|
list(db);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("Commands: add, passwd, disable, enable, list");
|
console.log("Commands: add, passwd, role, disable, enable, list");
|
||||||
|
console.log(`Roles: ${ROLES.join(", ")}`);
|
||||||
process.exit(command ? 1 : 0);
|
process.exit(command ? 1 : 0);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@ export function listRows(db, entity, query = {}) {
|
||||||
return { rows, total: rows.length };
|
return { rows, total: rows.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readRow(db, entity, id) {
|
export function readRow(db, entity, rawId) {
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
.get(id);
|
.get(id);
|
||||||
|
|
@ -161,10 +162,7 @@ export function readRow(db, entity, id) {
|
||||||
if (!row) throw new HttpError(404, "Not found.");
|
if (!row) throw new HttpError(404, "Not found.");
|
||||||
|
|
||||||
for (const ext of entity.extensions ?? []) {
|
for (const ext of entity.extensions ?? []) {
|
||||||
row[ext.key] =
|
row[ext.key] = readExtension(db, ext, id);
|
||||||
db
|
|
||||||
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
|
|
||||||
.get(id) ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of entity.children ?? []) {
|
for (const child of entity.children ?? []) {
|
||||||
|
|
@ -174,6 +172,29 @@ export function readRow(db, entity, id) {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A 1:1 side table is found one of two ways. `idColumn` is the
|
||||||
|
original: the side table's key IS the parent's id, which is how
|
||||||
|
regions and person_private work. `owner` is the same block children
|
||||||
|
already use — a foreign key column plus an optional kind discriminator
|
||||||
|
— and it exists because a timeline entry is keyed by (ref_kind,
|
||||||
|
ref_id) rather than by the event's own slug. Same upsert either way;
|
||||||
|
only the WHERE differs. */
|
||||||
|
function extensionWhere(ext, id) {
|
||||||
|
if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] };
|
||||||
|
const where = [`${ext.owner.column} = ?`];
|
||||||
|
const params = [id];
|
||||||
|
if (ext.owner.kindColumn) {
|
||||||
|
where.push(`${ext.owner.kindColumn} = ?`);
|
||||||
|
params.push(ext.owner.kindValue);
|
||||||
|
}
|
||||||
|
return { sql: where.join(" AND "), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExtension(db, ext, id) {
|
||||||
|
const { sql, params } = extensionWhere(ext, id);
|
||||||
|
return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function readChildren(db, child, ownerId) {
|
function readChildren(db, child, ownerId) {
|
||||||
const where = [`${child.owner.column} = ?`];
|
const where = [`${child.owner.column} = ?`];
|
||||||
const params = [ownerId];
|
const params = [ownerId];
|
||||||
|
|
@ -208,22 +229,43 @@ function readChildren(db, child, ownerId) {
|
||||||
|
|
||||||
/* ── Write ───────────────────────────────────────────────────── */
|
/* ── 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) {
|
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)) {
|
if (!auto) {
|
||||||
throw new HttpError(422, "Validation failed", {
|
if (entity.idKind === "slug" && !SLUG.test(id)) {
|
||||||
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
|
throw new HttpError(422, "Validation failed", {
|
||||||
});
|
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const exists = db
|
const exists = db
|
||||||
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
.get(id);
|
.get(id);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
throw new HttpError(422, "Validation failed", {
|
throw new HttpError(422, "Validation failed", {
|
||||||
[entity.idColumn]: "Already taken.",
|
[entity.idColumn]: "Already taken.",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { values, errors } = coerceRow(entity.columns, payload);
|
const { values, errors } = coerceRow(entity.columns, payload);
|
||||||
|
|
@ -231,24 +273,43 @@ export function createRow(db, entity, payload) {
|
||||||
throw new HttpError(422, "Validation failed", errors);
|
throw new HttpError(422, "Validation failed", errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = [entity.idColumn, ...Object.keys(values)];
|
let newId = id;
|
||||||
|
|
||||||
wrapDbErrors(() =>
|
wrapDbErrors(() =>
|
||||||
tx(db, () => {
|
tx(db, () => {
|
||||||
db.prepare(
|
if (auto) {
|
||||||
`INSERT INTO ${entity.table} (${names.join(", ")})
|
const names = Object.keys(values);
|
||||||
VALUES (${names.map(() => "?").join(", ")})`,
|
// Every column omitted is legitimate here: a blank entry that
|
||||||
).run(id, ...Object.values(values));
|
// 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);
|
writeExtensions(db, entity, newId, payload, values);
|
||||||
writeChildren(db, entity, id, payload, values);
|
writeChildren(db, entity, newId, payload, values);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return readRow(db, entity, id);
|
return readRow(db, entity, newId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRow(db, entity, id, payload) {
|
export function updateRow(db, entity, rawId, payload) {
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
const current = db
|
const current = db
|
||||||
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
.get(id);
|
.get(id);
|
||||||
|
|
@ -296,7 +357,8 @@ export function updateRow(db, entity, id, payload) {
|
||||||
return readRow(db, entity, id);
|
return readRow(db, entity, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRow(db, entity, id) {
|
export function deleteRow(db, entity, rawId) {
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
const result = wrapDbErrors(() =>
|
const result = wrapDbErrors(() =>
|
||||||
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
||||||
);
|
);
|
||||||
|
|
@ -309,8 +371,10 @@ function writeExtensions(db, entity, id, payload, parentValues) {
|
||||||
for (const ext of entity.extensions ?? []) {
|
for (const ext of entity.extensions ?? []) {
|
||||||
if (!applies(ext.when, parentValues)) {
|
if (!applies(ext.when, parentValues)) {
|
||||||
// The gate closed — the kind changed away from this side
|
// The gate closed — the kind changed away from this side
|
||||||
// table, so its row (and anything cascading off it) goes.
|
// table, or a checkbox was unticked — so its row (and anything
|
||||||
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
|
// cascading off it) goes.
|
||||||
|
const gone = extensionWhere(ext, id);
|
||||||
|
db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,21 +387,42 @@ function writeExtensions(db, entity, id, payload, parentValues) {
|
||||||
|
|
||||||
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
|
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
|
||||||
const names = [ext.idColumn, ...Object.keys(values)];
|
// The owning columns come first, then whatever the form sent.
|
||||||
|
const ownNames = [];
|
||||||
|
const ownParams = [];
|
||||||
|
if (ext.owner) {
|
||||||
|
ownNames.push(ext.owner.column);
|
||||||
|
ownParams.push(id);
|
||||||
|
if (ext.owner.kindColumn) {
|
||||||
|
ownNames.push(ext.owner.kindColumn);
|
||||||
|
ownParams.push(ext.owner.kindValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ownNames.push(ext.idColumn);
|
||||||
|
ownParams.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = [...ownNames, ...Object.keys(values)];
|
||||||
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
||||||
|
|
||||||
|
// What makes this row the same row on a second save. Defaults to
|
||||||
|
// the id column; an owned extension declares the unique index its
|
||||||
|
// owning columns form.
|
||||||
|
const conflict = ext.conflict ?? ownNames;
|
||||||
|
|
||||||
// Upsert rather than delete-and-insert: deleting a regions row
|
// Upsert rather than delete-and-insert: deleting a regions row
|
||||||
// would cascade its region_areas away underneath us. With every
|
// would cascade its region_areas away underneath us, and deleting
|
||||||
|
// a timeline row would take its people with it. With every
|
||||||
// optional column omitted there is nothing to set, so the
|
// optional column omitted there is nothing to set, so the
|
||||||
// conflict clause has to degrade to DO NOTHING or the SQL is
|
// conflict clause has to degrade to DO NOTHING or the SQL is
|
||||||
// syntactically invalid.
|
// syntactically invalid.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO ${ext.table} (${names.join(", ")})
|
`INSERT INTO ${ext.table} (${names.join(", ")})
|
||||||
VALUES (${names.map(() => "?").join(", ")})
|
VALUES (${names.map(() => "?").join(", ")})
|
||||||
ON CONFLICT(${ext.idColumn}) ${
|
ON CONFLICT(${conflict.join(", ")}) ${
|
||||||
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
||||||
}`,
|
}`,
|
||||||
).run(id, ...Object.values(values));
|
).run(...ownParams, ...Object.values(values));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,6 +613,19 @@ function wrapDbErrors(fn) {
|
||||||
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
|
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The polymorphic tables stand in for a foreign key with a
|
||||||
|
// BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none
|
||||||
|
// of the patterns above — so without this, pointing a content
|
||||||
|
// block, link or timeline entry at a row that isn't there is a 500
|
||||||
|
// rather than something the form can show.
|
||||||
|
const ghost = /^(\w+): no such (\w+)$/.exec(message);
|
||||||
|
if (ghost) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
`That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,15 @@ function collectGroups(entity) {
|
||||||
groups.push({
|
groups.push({
|
||||||
table: ext.table,
|
table: ext.table,
|
||||||
columns: ext.columns ?? [],
|
columns: ext.columns ?? [],
|
||||||
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])],
|
// An extension is keyed either by the parent's own id or by an
|
||||||
|
// owner block, the same one children use. Both sets of columns
|
||||||
|
// are filled in by the engine, never by the form.
|
||||||
|
engineSupplied: [
|
||||||
|
ext.idColumn,
|
||||||
|
ext.owner?.column,
|
||||||
|
ext.owner?.kindColumn,
|
||||||
|
...(ext.touch ? ["updated_at"] : []),
|
||||||
|
].filter(Boolean),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +73,7 @@ function collectGroups(entity) {
|
||||||
child.owner.column,
|
child.owner.column,
|
||||||
child.owner.kindColumn,
|
child.owner.kindColumn,
|
||||||
"sort_order",
|
"sort_order",
|
||||||
...Object.keys(child.owner.inherit ?? {}),
|
...Object.keys(child.owner.inherit ?? {}),
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
});
|
});
|
||||||
for (const nested of child.children ?? []) walk(nested);
|
for (const nested of child.children ?? []) walk(nested);
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,51 @@ const affiliationRole = [
|
||||||
bool("is_public"),
|
bool("is_public"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* The editable half of a timeline entry, shared by the standalone
|
||||||
|
editor and by the in_timeline extension on events and organizations.
|
||||||
|
|
||||||
|
occurred_on is text, not date. "2012" and "2025-07" are legitimate
|
||||||
|
values — a backfilled entry often knows the year and nothing more —
|
||||||
|
and the date coercion would reject both. `precision` is what says how
|
||||||
|
much of it to believe. */
|
||||||
|
const timelineFields = [
|
||||||
|
text("occurred_on"),
|
||||||
|
enumeration("precision", ["year", "month", "day"]),
|
||||||
|
text("title"),
|
||||||
|
text("blurb"),
|
||||||
|
text("meta"),
|
||||||
|
text("link_url"),
|
||||||
|
bool("is_featured"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The extension that the in_timeline checkbox drives. Ticked, the row
|
||||||
|
is upserted; unticked, writeExtensions deletes it. Both happen in the
|
||||||
|
parent's transaction, so the flag and the row cannot disagree.
|
||||||
|
|
||||||
|
The conflict target is the UNIQUE (ref_kind, ref_id) index from
|
||||||
|
migration 007, which is also what stops a second save creating a
|
||||||
|
duplicate instead of updating the first. */
|
||||||
|
const timelineExtension = (refKind) => ({
|
||||||
|
key: "timeline",
|
||||||
|
table: "timeline_entries",
|
||||||
|
owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind },
|
||||||
|
conflict: ["ref_kind", "ref_id"],
|
||||||
|
when: { column: "in_timeline", value: 1 },
|
||||||
|
columns: [
|
||||||
|
// Fixed for this end: an event's entry is always an event entry.
|
||||||
|
// Declared as a default rather than a form field so the column is
|
||||||
|
// written without asking.
|
||||||
|
enumeration(
|
||||||
|
"kind",
|
||||||
|
["milestone", "event", "organization", "award", "people"],
|
||||||
|
{ default: refKind === "organization" ? "organization" : refKind },
|
||||||
|
),
|
||||||
|
...timelineFields,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
/* The two polymorphic collections, parameterised by owner_kind. */
|
/* The two polymorphic collections, parameterised by owner_kind. */
|
||||||
const linksChild = (ownerKind) => ({
|
const linksChild = (ownerKind) => ({
|
||||||
key: "links",
|
key: "links",
|
||||||
|
|
@ -131,6 +176,26 @@ const blocksChild = (ownerKind) => ({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Hosts. One row is one host, ordered, each either an organization
|
||||||
|
or a person — the CHECK on event_hosts rejects both and the blank
|
||||||
|
filter drops neither, so the only bad row that reaches SQLite is
|
||||||
|
one with both selects filled, and that comes back keyed to the
|
||||||
|
row like any other field error.
|
||||||
|
|
||||||
|
Not parameterised the way links and blocks are: this table is
|
||||||
|
events-only, and the owner column says so.
|
||||||
|
|
||||||
|
sort_order isn't declared. The engine writes it from the row's
|
||||||
|
position because `order` is "sort_order", which is what makes
|
||||||
|
the first row the one v_events takes the logo and colour from. */
|
||||||
|
const hostsChild = {
|
||||||
|
key: "event_hosts",
|
||||||
|
table: "event_hosts",
|
||||||
|
owner: { column: "event_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("org_id"), text("person_id")],
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Organizations ───────────────────────────────────────────── */
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const organizations = {
|
const organizations = {
|
||||||
|
|
@ -169,9 +234,11 @@ const organizations = {
|
||||||
...placeColumns,
|
...placeColumns,
|
||||||
bool("is_published"),
|
bool("is_published"),
|
||||||
int("sort_order"),
|
int("sort_order"),
|
||||||
|
bool("in_timeline"),
|
||||||
],
|
],
|
||||||
|
|
||||||
extensions: [
|
extensions: [
|
||||||
|
timelineExtension("organization"),
|
||||||
{
|
{
|
||||||
key: "region",
|
key: "region",
|
||||||
table: "regions",
|
table: "regions",
|
||||||
|
|
@ -226,7 +293,7 @@ const events = {
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"section_id",
|
"section_id",
|
||||||
"host_org_id",
|
"event_type",
|
||||||
"date_label",
|
"date_label",
|
||||||
"starts_on",
|
"starts_on",
|
||||||
"status",
|
"status",
|
||||||
|
|
@ -234,14 +301,29 @@ const events = {
|
||||||
"sort_order",
|
"sort_order",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
],
|
],
|
||||||
filters: ["section_id", "status", "is_published", "host_org_id"],
|
// No host filter: hosts are rows in another table now, and the
|
||||||
|
// engine's filters are columns on this one. The events a host
|
||||||
|
// owns are on that host's own page.
|
||||||
|
filters: ["section_id", "event_type", "status", "is_published"],
|
||||||
search: ["title", "id", "theme"],
|
search: ["title", "id", "theme"],
|
||||||
order: "sort_order, starts_on DESC, title",
|
order: "sort_order, starts_on DESC, title",
|
||||||
},
|
},
|
||||||
|
|
||||||
columns: [
|
columns: [
|
||||||
text("section_id", { required: true }),
|
text("section_id", { required: true }),
|
||||||
text("host_org_id"),
|
|
||||||
|
// What kind of gathering, as against section_id's which band of
|
||||||
|
// the page. Declared required even though the column has a
|
||||||
|
// DEFAULT: every select renders a blank first option, so without
|
||||||
|
// it a new event files itself as a retreat while nobody is
|
||||||
|
// looking. An existing row always loads with its value set, so
|
||||||
|
// this only ever asks on create.
|
||||||
|
enumeration(
|
||||||
|
"event_type",
|
||||||
|
["retreat", "class", "workshop", "meeting", "other"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
|
||||||
text("title", { required: true }),
|
text("title", { required: true }),
|
||||||
text("theme"),
|
text("theme"),
|
||||||
text("tagline"),
|
text("tagline"),
|
||||||
|
|
@ -256,9 +338,13 @@ const events = {
|
||||||
text("gradient"),
|
text("gradient"),
|
||||||
bool("is_published"),
|
bool("is_published"),
|
||||||
int("sort_order"),
|
int("sort_order"),
|
||||||
|
bool("in_timeline"),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
extensions: [timelineExtension("event")],
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
|
hostsChild,
|
||||||
linksChild("event"),
|
linksChild("event"),
|
||||||
blocksChild("event"),
|
blocksChild("event"),
|
||||||
{
|
{
|
||||||
|
|
@ -485,7 +571,69 @@ const awards = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENTITIES = { organizations, events, people, teams, awards };
|
/* ── Timeline ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The history page's spine, and the only entity whose id the table
|
||||||
|
// assigns. There is nothing to slug: an entry referencing an event has
|
||||||
|
// no name of its own, and one gets created every time somebody ticks a
|
||||||
|
// checkbox. idKind "auto" is what lets createRow skip the id entirely.
|
||||||
|
//
|
||||||
|
// ref_kind and ref_id are writable here and only here. The extension on
|
||||||
|
// events and organizations owns those two columns for rows it created,
|
||||||
|
// which is why they aren't in timelineFields.
|
||||||
|
//
|
||||||
|
// Deleting an entry takes its people with it (ON DELETE CASCADE) and
|
||||||
|
// nothing points at an entry, so the delete-and-reinsert child engine
|
||||||
|
// is safe on this one.
|
||||||
|
const timeline = {
|
||||||
|
key: "timeline",
|
||||||
|
table: "timeline_entries",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "auto",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"kind",
|
||||||
|
"ref_kind",
|
||||||
|
"ref_id",
|
||||||
|
"occurred_on",
|
||||||
|
"title",
|
||||||
|
"is_featured",
|
||||||
|
"is_published",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["kind", "ref_kind", "is_featured", "is_published"],
|
||||||
|
search: ["title", "blurb", "meta", "ref_id"],
|
||||||
|
// Undated entries sort last rather than first, so a missing date
|
||||||
|
// reads as something to fix instead of something to scroll past.
|
||||||
|
order: "occurred_on IS NULL, occurred_on DESC, sort_order",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
enumeration(
|
||||||
|
"kind",
|
||||||
|
["milestone", "event", "organization", "award", "people"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
enumeration("ref_kind", ["event", "organization", "award", "person", "team"]),
|
||||||
|
text("ref_id"),
|
||||||
|
...timelineFields,
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "people",
|
||||||
|
table: "timeline_entry_people",
|
||||||
|
owner: { column: "entry_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("person_id", { required: true }), text("note")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITIES = { organizations, events, people, teams, awards, timeline };
|
||||||
|
|
||||||
/* ── Options for the form's select inputs ────────────────────── */
|
/* ── Options for the form's select inputs ────────────────────── */
|
||||||
|
|
||||||
|
|
@ -503,6 +651,22 @@ export const OPTION_QUERIES = {
|
||||||
teams:
|
teams:
|
||||||
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
|
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
|
||||||
|
|
||||||
|
// One flat list the ref picker filters by ref_kind, rather than five
|
||||||
|
// dropdowns of which four are always wrong. `kind` is the discriminator
|
||||||
|
// the client's filterBy matches on; org_kind disambiguates the label,
|
||||||
|
// since a region and a chapter can share a name.
|
||||||
|
timeline_refs: `
|
||||||
|
SELECT 'event' AS kind, id, title AS label FROM events
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'award', id, name FROM awards
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'person', id, display_name FROM people
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'team', id, name FROM teams
|
||||||
|
ORDER BY kind, label`,
|
||||||
|
|
||||||
// The awarding organization is folded into the label instead,
|
// The awarding organization is folded into the label instead,
|
||||||
// because a person can receive an award from any organization —
|
// because a person can receive an award from any organization —
|
||||||
// there is nothing to filter on, only something to disambiguate
|
// there is nothing to filter on, only something to disambiguate
|
||||||
|
|
|
||||||
|
|
@ -198,10 +198,14 @@ export async function requireAuth(c, next) {
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ROLES = ["viewer", "editor", "admin", "superadmin"];
|
||||||
|
const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 };
|
||||||
|
|
||||||
export function requireRole(...roles) {
|
export function requireRole(...roles) {
|
||||||
|
const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity));
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
if (!user || !roles.includes(user.role)) {
|
if (!user || (RANK[user.role] ?? 0) < need) {
|
||||||
return c.json({ error: "Not allowed." }, 403);
|
return c.json({ error: "Not allowed." }, 403);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ import { openDatabase, migrate } from "./db.js";
|
||||||
import { rateLimit } from "./rateLimit.js";
|
import { rateLimit } from "./rateLimit.js";
|
||||||
import content from "./routes/content.js";
|
import content from "./routes/content.js";
|
||||||
import people from "./routes/people.js";
|
import people from "./routes/people.js";
|
||||||
|
import history from "./routes/history.js";
|
||||||
import feedback from "./routes/feedback.js";
|
import feedback from "./routes/feedback.js";
|
||||||
import auth from "./routes/auth.js";
|
import auth from "./routes/auth.js";
|
||||||
import admin from "./routes/admin.js";
|
import admin from "./routes/admin.js";
|
||||||
|
import panel from "./routes/panel.js";
|
||||||
import adminEntities from "./routes/admin-entities.js";
|
import adminEntities from "./routes/admin-entities.js";
|
||||||
import { startSessionSweeper } from "./auth.js";
|
import { startSessionSweeper } from "./auth.js";
|
||||||
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
|
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
|
||||||
|
|
@ -53,12 +55,14 @@ app.get("/api/health", (c) =>
|
||||||
|
|
||||||
app.route("/api", content);
|
app.route("/api", content);
|
||||||
app.route("/api", people);
|
app.route("/api", people);
|
||||||
|
app.route("/api", history);
|
||||||
|
|
||||||
// Tighter limit on the write path than anything else gets.
|
// Tighter limit on the write path than anything else gets.
|
||||||
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
||||||
app.route("/api/feedback", feedback);
|
app.route("/api/feedback", feedback);
|
||||||
|
|
||||||
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
|
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
|
||||||
|
app.route("/api/admin/panel", panel);
|
||||||
app.route("/api/auth", auth);
|
app.route("/api/auth", auth);
|
||||||
app.route("/api/admin", admin);
|
app.route("/api/admin", admin);
|
||||||
app.route("/api/admin", adminEntities);
|
app.route("/api/admin", adminEntities);
|
||||||
|
|
|
||||||
279
server/src/migrations/007_timeline.sql
Normal file
279
server/src/migrations/007_timeline.sql
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 007 TIMELINE
|
||||||
|
--
|
||||||
|
-- The history page's spine. One row per thing worth putting on the
|
||||||
|
-- rail, and — this is the whole point — a row that points at an
|
||||||
|
-- event holds almost nothing of its own. Title, date and logo are
|
||||||
|
-- read back from `events` at query time, so editing the event edits
|
||||||
|
-- the timeline and there is no second copy to drift.
|
||||||
|
--
|
||||||
|
-- Decade headers are NOT here. There are four of them, they change
|
||||||
|
-- about never, and they are editorial voice rather than record; they
|
||||||
|
-- live in src/data/historyDecades.ts.
|
||||||
|
--
|
||||||
|
-- ref_kind + ref_id is polymorphic, matching content_blocks and
|
||||||
|
-- links rather than inventing a second pattern. SQLite can't express
|
||||||
|
-- that as a foreign key, so the triggers below do the work one
|
||||||
|
-- would, exactly as those two tables already do.
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- was 6 before this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE timeline_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
|
-- What the entry is about, which drives the marker and the body
|
||||||
|
-- layout on the page. Usually mirrors ref_kind; 'people' is the
|
||||||
|
-- exception, being a team ref rendered as a roster, and
|
||||||
|
-- 'milestone' is the free-standing case with no ref at all.
|
||||||
|
kind TEXT NOT NULL DEFAULT 'milestone'
|
||||||
|
CHECK (kind IN ('milestone', 'event', 'organization',
|
||||||
|
'award', 'people')),
|
||||||
|
|
||||||
|
ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award',
|
||||||
|
'person', 'team')),
|
||||||
|
ref_id TEXT,
|
||||||
|
|
||||||
|
-- Null inherits from the referenced row: an event's starts_on. A
|
||||||
|
-- hand-authored entry has to supply its own, which the descriptor
|
||||||
|
-- can't require conditionally — the read layer reports an entry
|
||||||
|
-- with neither rather than the table refusing it.
|
||||||
|
occurred_on TEXT,
|
||||||
|
|
||||||
|
-- How much of occurred_on is trustworthy. Backfilled rows often
|
||||||
|
-- have a full date where only the year is actually known, and
|
||||||
|
-- 'year' is what routes them to "Elsewhere in 2009" instead of
|
||||||
|
-- asserting a month nobody can source.
|
||||||
|
precision TEXT NOT NULL DEFAULT 'day'
|
||||||
|
CHECK (precision IN ('year', 'month', 'day')),
|
||||||
|
|
||||||
|
-- All null-inherits-from-the-ref. Filling one in is an override,
|
||||||
|
-- for when the timeline wants to say something the event card
|
||||||
|
-- doesn't.
|
||||||
|
title TEXT,
|
||||||
|
blurb TEXT,
|
||||||
|
meta TEXT,
|
||||||
|
link_url TEXT,
|
||||||
|
|
||||||
|
is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)),
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
-- Half a reference is worse than none: it would resolve to a link
|
||||||
|
-- with no destination and no way to notice.
|
||||||
|
CHECK ((ref_kind IS NULL) = (ref_id IS NULL)),
|
||||||
|
|
||||||
|
-- One timeline entry per referenced record, which is what makes
|
||||||
|
-- the in_timeline checkbox an upsert rather than a duplicate
|
||||||
|
-- factory. SQLite permits any number of NULL pairs here, so
|
||||||
|
-- hand-authored entries are unaffected.
|
||||||
|
UNIQUE (ref_kind, ref_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX timeline_entries_date_idx
|
||||||
|
ON timeline_entries (is_published, occurred_on DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- Who an entry is about, when it isn't a whole team. A 'people'
|
||||||
|
-- entry naming a team resolves its roster through v_org_leadership
|
||||||
|
-- instead and leaves this table empty; this is for the cases where
|
||||||
|
-- the list is editorial rather than structural.
|
||||||
|
--
|
||||||
|
-- Safe for the CRUD engine's delete-and-reinsert because nothing
|
||||||
|
-- references these rows.
|
||||||
|
CREATE TABLE timeline_entry_people (
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE,
|
||||||
|
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||||
|
note TEXT, -- 'Founding lead'
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (entry_id, person_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX timeline_entry_people_person_idx
|
||||||
|
ON timeline_entry_people (person_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The checkbox on the event and organization editors ─────────
|
||||||
|
--
|
||||||
|
-- Not a denormalised copy of "does a timeline row exist" — it is the
|
||||||
|
-- gate the admin descriptor reads. Ticked, the extension upserts a
|
||||||
|
-- timeline_entries row; unticked, the engine deletes it. The flag
|
||||||
|
-- and the row are written in the same transaction, so they cannot
|
||||||
|
-- disagree.
|
||||||
|
ALTER TABLE events
|
||||||
|
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (in_timeline IN (0, 1));
|
||||||
|
|
||||||
|
ALTER TABLE organizations
|
||||||
|
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (in_timeline IN (0, 1));
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Integrity for the polymorphic reference ────────────────────
|
||||||
|
|
||||||
|
CREATE TRIGGER timeline_entries_ref_exists
|
||||||
|
BEFORE INSERT ON timeline_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE
|
||||||
|
WHEN new.ref_kind = 'event'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such event')
|
||||||
|
WHEN new.ref_kind = 'organization'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such organization')
|
||||||
|
WHEN new.ref_kind = 'award'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such award')
|
||||||
|
WHEN new.ref_kind = 'person'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such person')
|
||||||
|
WHEN new.ref_kind = 'team'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such team')
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The same check on update, because the standalone editor can
|
||||||
|
-- repoint an entry at a different record.
|
||||||
|
CREATE TRIGGER timeline_entries_ref_exists_update
|
||||||
|
BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE
|
||||||
|
WHEN new.ref_kind = 'event'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such event')
|
||||||
|
WHEN new.ref_kind = 'organization'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such organization')
|
||||||
|
WHEN new.ref_kind = 'award'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such award')
|
||||||
|
WHEN new.ref_kind = 'person'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such person')
|
||||||
|
WHEN new.ref_kind = 'team'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
|
||||||
|
THEN RAISE(ABORT, 'timeline_entries: no such team')
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
-- Deleting the record deletes its entry. Separate triggers rather
|
||||||
|
-- than editing the existing *_cleanup ones, so this migration adds
|
||||||
|
-- and never rewrites.
|
||||||
|
CREATE TRIGGER timeline_events_cleanup
|
||||||
|
AFTER DELETE ON events
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER timeline_organizations_cleanup
|
||||||
|
AFTER DELETE ON organizations
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER timeline_awards_cleanup
|
||||||
|
AFTER DELETE ON awards
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER timeline_people_cleanup
|
||||||
|
AFTER DELETE ON people
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER timeline_teams_cleanup
|
||||||
|
AFTER DELETE ON teams
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
-- updated_at, with the same WHEN guard as the other touch triggers
|
||||||
|
-- so an explicit value passes through untouched on import.
|
||||||
|
CREATE TRIGGER timeline_entries_touch
|
||||||
|
AFTER UPDATE ON timeline_entries
|
||||||
|
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||||
|
BEGIN
|
||||||
|
UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Read view ──────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Every fallback the page depends on, resolved once here rather than
|
||||||
|
-- restated by each route. An entry with no title of its own takes
|
||||||
|
-- the referenced record's name; with no date, the event's starts_on.
|
||||||
|
--
|
||||||
|
-- org_kind rides along because /regions, /chapters and /partners are
|
||||||
|
-- three different routes and only this table knows which a slug is.
|
||||||
|
--
|
||||||
|
-- effective_date is the sort key. An entry that ended up with no
|
||||||
|
-- date at all sorts last rather than vanishing, so a missing one is
|
||||||
|
-- visible in the admin instead of silently absent from the page.
|
||||||
|
CREATE VIEW v_timeline AS
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.kind,
|
||||||
|
t.ref_kind,
|
||||||
|
t.ref_id,
|
||||||
|
t.precision,
|
||||||
|
t.is_featured,
|
||||||
|
t.is_published,
|
||||||
|
t.sort_order,
|
||||||
|
|
||||||
|
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
t.title,
|
||||||
|
e.title,
|
||||||
|
o.name,
|
||||||
|
aw.name,
|
||||||
|
p.display_name,
|
||||||
|
tm.name
|
||||||
|
) AS effective_title,
|
||||||
|
|
||||||
|
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
|
||||||
|
AS effective_blurb,
|
||||||
|
t.meta,
|
||||||
|
t.link_url,
|
||||||
|
|
||||||
|
-- Filename only. The directory is the frontend's business.
|
||||||
|
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
|
||||||
|
AS effective_logo,
|
||||||
|
|
||||||
|
o.kind AS org_kind,
|
||||||
|
tm.org_id AS team_org_id,
|
||||||
|
tm.name AS team_name,
|
||||||
|
|
||||||
|
-- Whether the referenced record is itself visible. An entry must not
|
||||||
|
-- outlive the thing it points at being unpublished — a draft event
|
||||||
|
-- would otherwise leak its title and date onto a public page. Null
|
||||||
|
-- for a standalone milestone, which answers to nothing but its own
|
||||||
|
-- is_published.
|
||||||
|
CASE t.ref_kind
|
||||||
|
WHEN 'event' THEN e.is_published
|
||||||
|
WHEN 'organization' THEN o.is_published
|
||||||
|
WHEN 'person' THEN p.is_published
|
||||||
|
WHEN 'team' THEN tm.is_published
|
||||||
|
ELSE NULL
|
||||||
|
END AS ref_is_published,
|
||||||
|
t.occurred_on,
|
||||||
|
t.title AS title_override
|
||||||
|
FROM timeline_entries t
|
||||||
|
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
|
||||||
|
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
|
||||||
|
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
|
||||||
|
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
|
||||||
|
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
|
||||||
|
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
|
||||||
|
AND pa.id = (SELECT MIN(id) FROM person_awards
|
||||||
|
WHERE award_id = t.ref_id);
|
||||||
|
|
||||||
|
PRAGMA user_version = 7;
|
||||||
85
server/src/migrations/008_timeline_view.sql
Normal file
85
server/src/migrations/008_timeline_view.sql
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 008 v_timeline
|
||||||
|
--
|
||||||
|
-- 007's tables, indexes and all eight triggers landed; its view did
|
||||||
|
-- not. This file creates it, and nothing else.
|
||||||
|
--
|
||||||
|
-- The definition below is byte-identical to the one at the foot of
|
||||||
|
-- 007. That is deliberate: a fresh database built from 007 and an
|
||||||
|
-- existing one upgraded through 008 must end up with the same view,
|
||||||
|
-- or a restore from backup six months from now produces a subtly
|
||||||
|
-- different site. Leave 007 exactly as it is.
|
||||||
|
--
|
||||||
|
-- No BEGIN...END anywhere in this file — two plain statements and a
|
||||||
|
-- pragma — so a runner that splits on semicolons treats it the same
|
||||||
|
-- way one that doesn't would. 007's triggers are the only place in
|
||||||
|
-- the schema where that distinction bites, and they are already in.
|
||||||
|
--
|
||||||
|
-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and
|
||||||
|
-- dropping a view touches no data.
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- reads 7 before this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_timeline;
|
||||||
|
|
||||||
|
CREATE VIEW v_timeline AS
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.kind,
|
||||||
|
t.ref_kind,
|
||||||
|
t.ref_id,
|
||||||
|
t.precision,
|
||||||
|
t.is_featured,
|
||||||
|
t.is_published,
|
||||||
|
t.sort_order,
|
||||||
|
|
||||||
|
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
|
||||||
|
|
||||||
|
COALESCE(
|
||||||
|
t.title,
|
||||||
|
e.title,
|
||||||
|
o.name,
|
||||||
|
aw.name,
|
||||||
|
p.display_name,
|
||||||
|
tm.name
|
||||||
|
) AS effective_title,
|
||||||
|
|
||||||
|
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
|
||||||
|
AS effective_blurb,
|
||||||
|
t.meta,
|
||||||
|
t.link_url,
|
||||||
|
|
||||||
|
-- Filename only. The directory is the frontend's business.
|
||||||
|
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
|
||||||
|
AS effective_logo,
|
||||||
|
|
||||||
|
o.kind AS org_kind,
|
||||||
|
tm.org_id AS team_org_id,
|
||||||
|
tm.name AS team_name,
|
||||||
|
|
||||||
|
-- Whether the referenced record is itself visible. An entry must not
|
||||||
|
-- outlive the thing it points at being unpublished — a draft event
|
||||||
|
-- would otherwise leak its title and date onto a public page. Null
|
||||||
|
-- for a standalone milestone, which answers to nothing but its own
|
||||||
|
-- is_published.
|
||||||
|
CASE t.ref_kind
|
||||||
|
WHEN 'event' THEN e.is_published
|
||||||
|
WHEN 'organization' THEN o.is_published
|
||||||
|
WHEN 'person' THEN p.is_published
|
||||||
|
WHEN 'team' THEN tm.is_published
|
||||||
|
ELSE NULL
|
||||||
|
END AS ref_is_published,
|
||||||
|
t.occurred_on,
|
||||||
|
t.title AS title_override
|
||||||
|
FROM timeline_entries t
|
||||||
|
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
|
||||||
|
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
|
||||||
|
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
|
||||||
|
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
|
||||||
|
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
|
||||||
|
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
|
||||||
|
AND pa.id = (SELECT MIN(id) FROM person_awards
|
||||||
|
WHERE award_id = t.ref_id);
|
||||||
|
|
||||||
|
PRAGMA user_version = 8;
|
||||||
86
server/src/migrations/009_superadmin.sql
Normal file
86
server/src/migrations/009_superadmin.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 009_superadmin.sql
|
||||||
|
--
|
||||||
|
-- Adds a third role above 'admin'. A CHECK constraint can't be
|
||||||
|
-- altered in place, so the table is rebuilt — the recipe from the
|
||||||
|
-- SQLite docs, in the order it has to happen.
|
||||||
|
--
|
||||||
|
-- Foreign keys are OFF for the duration on purpose. `sessions`
|
||||||
|
-- references admin_users(id), and:
|
||||||
|
--
|
||||||
|
-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE
|
||||||
|
-- CASCADE and empties `sessions` — everyone signed out;
|
||||||
|
-- * with FKs ON, the RENAME afterwards tries to rewrite the
|
||||||
|
-- REFERENCES clause in `sessions` and fails, because the table
|
||||||
|
-- it points at no longer exists.
|
||||||
|
--
|
||||||
|
-- With them OFF neither happens: `sessions` keeps pointing at the
|
||||||
|
-- name "admin_users", which the rename puts back underneath it.
|
||||||
|
--
|
||||||
|
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
|
||||||
|
-- migration runner wraps each file in BEGIN/COMMIT, this file will
|
||||||
|
-- appear to work and then fail at the rename. Check the runner
|
||||||
|
-- before applying, or run this one by hand:
|
||||||
|
--
|
||||||
|
-- sudo systemctl stop ngu-api
|
||||||
|
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql
|
||||||
|
-- sudo systemctl start ngu-api
|
||||||
|
--
|
||||||
|
-- Verify after:
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- 9
|
||||||
|
-- PRAGMA foreign_key_check; -- no rows
|
||||||
|
-- SELECT email, role FROM admin_users;
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE admin_users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
-- Stored lowercased. The application lowercases on every read
|
||||||
|
-- and write, so the UNIQUE index is genuinely case-insensitive
|
||||||
|
-- without depending on a collation.
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
|
||||||
|
-- Nullable so a Google-only account can exist later with no
|
||||||
|
-- password at all. A row with both can use either route in.
|
||||||
|
password_hash TEXT,
|
||||||
|
|
||||||
|
-- Google's stable subject id. Nullable, unique when present —
|
||||||
|
-- SQLite allows any number of NULLs in a unique index.
|
||||||
|
google_sub TEXT UNIQUE,
|
||||||
|
|
||||||
|
-- Listed low to high. The application treats these as a ladder,
|
||||||
|
-- not a set: 'superadmin' passes every check 'admin' passes.
|
||||||
|
-- The default stays 'admin' — a new account should never arrive
|
||||||
|
-- at the top of the ladder by accident.
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin'
|
||||||
|
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
last_login_at TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- Columns listed explicitly rather than SELECT *, so this breaks
|
||||||
|
-- loudly if the old shape isn't what this file assumes.
|
||||||
|
INSERT INTO admin_users_new
|
||||||
|
(id, created_at, email, name, password_hash, google_sub,
|
||||||
|
role, is_active, last_login_at)
|
||||||
|
SELECT
|
||||||
|
id, created_at, email, name, password_hash, google_sub,
|
||||||
|
role, is_active, last_login_at
|
||||||
|
FROM admin_users;
|
||||||
|
|
||||||
|
DROP TABLE admin_users;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users_new RENAME TO admin_users;
|
||||||
|
|
||||||
|
-- Informational: prints offending rows and returns nothing if the
|
||||||
|
-- rebuild left the graph intact.
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
PRAGMA user_version = 9; -- ← set to this migration's number
|
||||||
97
server/src/migrations/010_editor_role.sql
Normal file
97
server/src/migrations/010_editor_role.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 010_editor_role.sql
|
||||||
|
--
|
||||||
|
-- Adds 'editor' between viewer and admin: can create and update,
|
||||||
|
-- can't delete.
|
||||||
|
--
|
||||||
|
-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's
|
||||||
|
-- CHECK to the four-role list below, leave its user_version at 8,
|
||||||
|
-- and throw this file away. Two rebuilds of the same table to
|
||||||
|
-- reach the same shape is pure risk for no gain.
|
||||||
|
--
|
||||||
|
-- Same rebuild as 008, for the same reason: a CHECK constraint
|
||||||
|
-- can't be altered in place. Foreign keys stay OFF throughout
|
||||||
|
-- because `sessions` cascades from this table — with them on, the
|
||||||
|
-- DROP empties your session table and the RENAME then fails.
|
||||||
|
--
|
||||||
|
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
|
||||||
|
-- migration runner wraps each file in BEGIN/COMMIT, this fails at
|
||||||
|
-- the rename. Same drill as last time:
|
||||||
|
--
|
||||||
|
-- sudo systemctl stop ngu-api
|
||||||
|
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql
|
||||||
|
-- sudo systemctl start ngu-api
|
||||||
|
--
|
||||||
|
-- Verify after:
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- 9
|
||||||
|
-- PRAGMA foreign_key_check; -- no rows
|
||||||
|
-- SELECT email, role FROM admin_users;
|
||||||
|
--
|
||||||
|
-- No existing row changes meaning: an 'admin' stays an 'admin'.
|
||||||
|
-- Nobody is demoted into the new role automatically, because the
|
||||||
|
-- accounts that most want it are the ones you'd notice least.
|
||||||
|
--
|
||||||
|
-- If a fifth role ever comes up, this is the moment to stop using
|
||||||
|
-- a CHECK and make `role` an FK to a small admin_roles table —
|
||||||
|
-- then adding one is an INSERT. Not worth a third rebuild today,
|
||||||
|
-- since the rank ladder lives in auth.js either way.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE admin_users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
-- Stored lowercased. The application lowercases on every read
|
||||||
|
-- and write, so the UNIQUE index is genuinely case-insensitive
|
||||||
|
-- without depending on a collation.
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
|
||||||
|
-- Nullable so a Google-only account can exist later with no
|
||||||
|
-- password at all. A row with both can use either route in.
|
||||||
|
password_hash TEXT,
|
||||||
|
|
||||||
|
-- Google's stable subject id. Nullable, unique when present —
|
||||||
|
-- SQLite allows any number of NULLs in a unique index.
|
||||||
|
google_sub TEXT UNIQUE,
|
||||||
|
|
||||||
|
-- Listed low to high. The application treats these as a ladder,
|
||||||
|
-- not a set: each one passes every check the one below it
|
||||||
|
-- passes. The default stays 'admin' so no existing tooling
|
||||||
|
-- starts creating accounts with different powers than it did
|
||||||
|
-- yesterday.
|
||||||
|
--
|
||||||
|
-- viewer read
|
||||||
|
-- editor + create and update
|
||||||
|
-- admin + delete
|
||||||
|
-- superadmin + accounts, roles and sessions
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin'
|
||||||
|
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
last_login_at TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- Columns listed explicitly rather than SELECT *, so this breaks
|
||||||
|
-- loudly if the old shape isn't what this file assumes.
|
||||||
|
INSERT INTO admin_users_new
|
||||||
|
(id, created_at, email, name, password_hash, google_sub,
|
||||||
|
role, is_active, last_login_at)
|
||||||
|
SELECT
|
||||||
|
id, created_at, email, name, password_hash, google_sub,
|
||||||
|
role, is_active, last_login_at
|
||||||
|
FROM admin_users;
|
||||||
|
|
||||||
|
DROP TABLE admin_users;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users_new RENAME TO admin_users;
|
||||||
|
|
||||||
|
-- Informational: prints offending rows, returns nothing if the
|
||||||
|
-- rebuild left the graph intact.
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
PRAGMA user_version = 10; -- ← set to this migration's number
|
||||||
30
server/src/migrations/011_award_published.sql
Normal file
30
server/src/migrations/011_award_published.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 011 AWARDS CAN BE DRAFTED
|
||||||
|
--
|
||||||
|
-- awards was written when an award was a line on a person's
|
||||||
|
-- record: created, named, done. Now each one has a URL, and
|
||||||
|
-- there is no way to add a row without it being live the moment
|
||||||
|
-- it saves.
|
||||||
|
--
|
||||||
|
-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award
|
||||||
|
-- that exists today is already public and backfilling the other
|
||||||
|
-- way round would take the lot offline.
|
||||||
|
--
|
||||||
|
-- After this:
|
||||||
|
-- · add bool("is_published") to the awards descriptor in
|
||||||
|
-- admin-schema.js, and the matching checkbox in adminSchema.js
|
||||||
|
-- (PUBLISH_FIELDS covers both it and sort_order)
|
||||||
|
-- · add AND a.is_published = 1 to the three award queries in
|
||||||
|
-- content.js — the /awards list, /awards/:id, and the
|
||||||
|
-- recipient_count subquery in attachAwards
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- was 7 before this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE awards
|
||||||
|
ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1
|
||||||
|
CHECK (is_published IN (0, 1));
|
||||||
|
|
||||||
|
CREATE INDEX awards_published_idx ON awards (is_published, sort_order);
|
||||||
|
|
||||||
|
PRAGMA user_version = 11; -- ← set to this migration's number
|
||||||
135
server/src/migrations/012_event_hosts.sql
Normal file
135
server/src/migrations/012_event_hosts.sql
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE
|
||||||
|
--
|
||||||
|
-- host_org_id said two things that turned out to be wrong: that an
|
||||||
|
-- event has exactly one host, and that the host is an
|
||||||
|
-- organization. A retreat can be run jointly by two regions, and
|
||||||
|
-- some events are one person's.
|
||||||
|
--
|
||||||
|
-- Two nullable foreign keys rather than a polymorphic
|
||||||
|
-- host_kind/host_id pair. There are only ever two kinds, and this
|
||||||
|
-- way the references stay real and cascade on their own instead of
|
||||||
|
-- needing the trigger treatment timeline_entries has. CASCADE here
|
||||||
|
-- does what SET NULL used to do on the column: deleting an
|
||||||
|
-- organization drops it from the host list and leaves the event
|
||||||
|
-- standing.
|
||||||
|
--
|
||||||
|
-- The first host by sort_order is the one that supplies the logo
|
||||||
|
-- and colour fallbacks. A person supplies neither — `photo` is a
|
||||||
|
-- headshot, not a logo, and people have no colour — so an event
|
||||||
|
-- hosted only by a person and carrying no colour of its own falls
|
||||||
|
-- through to the section default. That's the view doing nothing
|
||||||
|
-- rather than a rule anybody has to remember.
|
||||||
|
--
|
||||||
|
-- host_org_id stays in place here, unread. 013 drops it: that
|
||||||
|
-- needs v_events and events_host_idx gone first, and it shouldn't
|
||||||
|
-- share a deploy with the table replacing it.
|
||||||
|
--
|
||||||
|
-- No trigger bodies in this file, so the views can ride along.
|
||||||
|
--
|
||||||
|
-- After this:
|
||||||
|
-- · event_hosts child collection in admin-schema.js and
|
||||||
|
-- adminSchema.js; the host_org_id field comes out of the
|
||||||
|
-- events Identity group in both
|
||||||
|
-- · shapeEvent in content.js emits `hosts`, not `host`
|
||||||
|
-- · the organization page's hosted-events query joins
|
||||||
|
-- event_hosts instead of reading host_org_id
|
||||||
|
-- · eventData.js filters on hosts[], EventDetail renders a list
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- reads 11 before this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE event_hosts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
|
||||||
|
org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
person_id TEXT REFERENCES people (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so
|
||||||
|
-- <> between them is xor.
|
||||||
|
CHECK ((org_id IS NULL) <> (person_id IS NULL))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order);
|
||||||
|
CREATE INDEX event_hosts_org_idx ON event_hosts (org_id);
|
||||||
|
CREATE INDEX event_hosts_person_idx ON event_hosts (person_id);
|
||||||
|
|
||||||
|
-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite
|
||||||
|
-- treats NULLs as distinct, so the same organization could be
|
||||||
|
-- added twice with the person column null both times. Two partial
|
||||||
|
-- indexes, one per kind.
|
||||||
|
CREATE UNIQUE INDEX event_hosts_org_uniq
|
||||||
|
ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX event_hosts_person_uniq
|
||||||
|
ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO event_hosts (event_id, org_id, sort_order)
|
||||||
|
SELECT id, host_org_id, 0
|
||||||
|
FROM events
|
||||||
|
WHERE host_org_id IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Views ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Every host of every event, resolved to a name and the bits the
|
||||||
|
-- fallbacks need. is_published travels with the row rather than
|
||||||
|
-- being filtered here, so the public routes can hide an
|
||||||
|
-- unpublished host and the admin can still see one.
|
||||||
|
CREATE VIEW v_event_hosts AS
|
||||||
|
SELECT
|
||||||
|
eh.id,
|
||||||
|
eh.event_id,
|
||||||
|
eh.sort_order,
|
||||||
|
CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END
|
||||||
|
AS host_kind,
|
||||||
|
COALESCE(eh.org_id, eh.person_id) AS host_id,
|
||||||
|
COALESCE(o.name, p.display_name) AS host_name,
|
||||||
|
o.kind AS host_org_kind,
|
||||||
|
o.logo AS host_logo,
|
||||||
|
o.color AS host_color,
|
||||||
|
p.photo AS host_photo,
|
||||||
|
COALESCE(o.is_published, p.is_published) AS host_is_published
|
||||||
|
FROM event_hosts eh
|
||||||
|
LEFT JOIN organizations o ON o.id = eh.org_id
|
||||||
|
LEFT JOIN people p ON p.id = eh.person_id;
|
||||||
|
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_events;
|
||||||
|
|
||||||
|
-- Same contract as before — effective_org_logo, effective_color,
|
||||||
|
-- effective_status — with the first host standing in for what
|
||||||
|
-- host_org_id used to be. host_org_id itself is still selected by
|
||||||
|
-- e.*, and is dead weight until 013 removes it.
|
||||||
|
--
|
||||||
|
-- A correlated subquery rather than GROUP BY with bare columns
|
||||||
|
-- alongside MIN(sort_order): the bare-column form works in SQLite
|
||||||
|
-- and nowhere else, and it leaves a tie on sort_order resolving
|
||||||
|
-- differently run to run. At a few dozen events the extra lookup
|
||||||
|
-- costs nothing worth measuring.
|
||||||
|
CREATE VIEW v_events AS
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
h.host_kind,
|
||||||
|
h.host_id,
|
||||||
|
h.host_name,
|
||||||
|
h.host_org_kind,
|
||||||
|
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
|
||||||
|
COALESCE(e.color, h.host_color) AS effective_color,
|
||||||
|
COALESCE(
|
||||||
|
e.status,
|
||||||
|
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
||||||
|
THEN 'past' ELSE 'upcoming' END
|
||||||
|
) AS effective_status
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN v_event_hosts h
|
||||||
|
ON h.id = (
|
||||||
|
SELECT x.id
|
||||||
|
FROM v_event_hosts x
|
||||||
|
WHERE x.event_id = e.id
|
||||||
|
ORDER BY x.sort_order, x.id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAGMA user_version = 12; -- ← set to this migration's number
|
||||||
48
server/src/migrations/013_drop_host_org_id.sql
Normal file
48
server/src/migrations/013_drop_host_org_id.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 013 DROP events.host_org_id
|
||||||
|
--
|
||||||
|
-- Run this only once 012 is deployed and the site is reading
|
||||||
|
-- hosts off event_hosts. Until then the column is the rollback:
|
||||||
|
-- restoring the old v_events is one CREATE VIEW away.
|
||||||
|
--
|
||||||
|
-- SQLite refuses DROP COLUMN while the column is named by an index
|
||||||
|
-- or a view, so both go first and the view comes back unchanged
|
||||||
|
-- apart from no longer selecting e.host_org_id through e.*. No
|
||||||
|
-- table rebuild, so no PRAGMA foreign_keys dance.
|
||||||
|
--
|
||||||
|
-- Check nothing still reads it before running:
|
||||||
|
-- grep -rn host_org_id server/src client/src
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- reads 12 before this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS events_host_idx;
|
||||||
|
DROP VIEW IF EXISTS v_events;
|
||||||
|
|
||||||
|
ALTER TABLE events DROP COLUMN host_org_id;
|
||||||
|
|
||||||
|
CREATE VIEW v_events AS
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
h.host_kind,
|
||||||
|
h.host_id,
|
||||||
|
h.host_name,
|
||||||
|
h.host_org_kind,
|
||||||
|
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
|
||||||
|
COALESCE(e.color, h.host_color) AS effective_color,
|
||||||
|
COALESCE(
|
||||||
|
e.status,
|
||||||
|
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
||||||
|
THEN 'past' ELSE 'upcoming' END
|
||||||
|
) AS effective_status
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN v_event_hosts h
|
||||||
|
ON h.id = (
|
||||||
|
SELECT x.id
|
||||||
|
FROM v_event_hosts x
|
||||||
|
WHERE x.event_id = e.id
|
||||||
|
ORDER BY x.sort_order, x.id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAGMA user_version = 13; -- ← set to this migration's number
|
||||||
36
server/src/migrations/014_event-type.sql
Normal file
36
server/src/migrations/014_event-type.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- EVENT TYPE
|
||||||
|
--
|
||||||
|
-- What kind of gathering a row is, independent of which band of
|
||||||
|
-- the Retreats page it appears in. section_id answers "whose is
|
||||||
|
-- it" — national, regional, partner. event_type answers "what is
|
||||||
|
-- it", and the two cross freely: a region can run a class, a
|
||||||
|
-- partner can run a retreat.
|
||||||
|
--
|
||||||
|
-- An enum column rather than a lookup table, unlike event_sections.
|
||||||
|
-- Sections need a table because Retreats.tsx owns presentation
|
||||||
|
-- keyed on the id, so an unrecognised value makes an event vanish
|
||||||
|
-- with no error anywhere. A type carries no presentation of its
|
||||||
|
-- own — an unknown value renders as its own name rather than
|
||||||
|
-- disappearing — so the CHECK is enough, and the column matches
|
||||||
|
-- `status` and event_people.role in shape.
|
||||||
|
--
|
||||||
|
-- DEFAULT 'retreat' backfills every existing row, which is what
|
||||||
|
-- they all are. That default is also what lets the admin clear the
|
||||||
|
-- field: coerceValue omits an empty NOT NULL column rather than
|
||||||
|
-- writing NULL into it.
|
||||||
|
--
|
||||||
|
-- No change to v_events: it is SELECT e.*, so the column arrives on
|
||||||
|
-- both /events and /events/:id for free.
|
||||||
|
--
|
||||||
|
-- No BEGIN...END in this file, so nothing after it is dropped by
|
||||||
|
-- the migration runner.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE events
|
||||||
|
ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat'
|
||||||
|
CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other'));
|
||||||
|
|
||||||
|
-- Mirrors events_section_idx: the public list filters on published
|
||||||
|
-- rows and orders by sort_order, whatever it is narrowing by.
|
||||||
|
CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order);
|
||||||
40
server/src/migrations/015_event-scopes.sql
Normal file
40
server/src/migrations/015_event-scopes.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- EVENT SCOPES
|
||||||
|
--
|
||||||
|
-- event_sections is a scope list and always was: whose gathering
|
||||||
|
-- this is, not which band of a page it lands in. The name stuck
|
||||||
|
-- because for three values those two things coincided. They stop
|
||||||
|
-- coinciding here — local, international and other are real scopes
|
||||||
|
-- that Retreats.tsx does not draw a band for.
|
||||||
|
--
|
||||||
|
-- Nothing is renamed. events.section_id keeps its name and its
|
||||||
|
-- foreign key, and this file only touches rows. A column rename
|
||||||
|
-- would have to walk the descriptors, the shaper, the hook, the
|
||||||
|
-- section prop and the view, for a word.
|
||||||
|
--
|
||||||
|
-- Order is scope order, widest first, with Other last where an
|
||||||
|
-- unclassified row belongs. Gaps of ten leave room to slot a scope
|
||||||
|
-- in later without renumbering the ones around it.
|
||||||
|
--
|
||||||
|
-- The three UPDATEs correct the existing rows' labels: "National
|
||||||
|
-- Retreats" was a page heading living in a scope table, and now
|
||||||
|
-- that a scope can hold a class it reads wrong in the admin's
|
||||||
|
-- dropdown. Retreats.tsx owns its own band titles and never read
|
||||||
|
-- these, so nothing on the public site moves.
|
||||||
|
--
|
||||||
|
-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand
|
||||||
|
-- on the box before this shipped, re-running is a no-op instead of
|
||||||
|
-- a constraint error.
|
||||||
|
--
|
||||||
|
-- No BEGIN...END, so nothing after this file is dropped by the
|
||||||
|
-- migration runner.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national';
|
||||||
|
UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional';
|
||||||
|
UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES
|
||||||
|
('local', 'Local', 30),
|
||||||
|
('international', 'International', 40),
|
||||||
|
('other', 'Other', 60);
|
||||||
|
|
@ -63,14 +63,14 @@ entities.get("/:entity/:id", (c) => {
|
||||||
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
|
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
|
||||||
});
|
});
|
||||||
|
|
||||||
entities.post("/:entity", requireRole("admin"), async (c) => {
|
entities.post("/:entity", requireRole("editor"), async (c) => {
|
||||||
const entity = entityOr404(c);
|
const entity = entityOr404(c);
|
||||||
const row = createRow(c.get("db"), entity, await json(c));
|
const row = createRow(c.get("db"), entity, await json(c));
|
||||||
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
|
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
|
||||||
return c.json({ row }, 201, NO_STORE);
|
return c.json({ row }, 201, NO_STORE);
|
||||||
});
|
});
|
||||||
|
|
||||||
entities.patch("/:entity/:id", requireRole("admin"), async (c) => {
|
entities.patch("/:entity/:id", requireRole("editor"), async (c) => {
|
||||||
const entity = entityOr404(c);
|
const entity = entityOr404(c);
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const row = updateRow(c.get("db"), entity, id, await json(c));
|
const row = updateRow(c.get("db"), entity, id, await json(c));
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ admin.get("/feedback", (c) => {
|
||||||
{ status?, admin_note? } — either, both, partial.
|
{ status?, admin_note? } — either, both, partial.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
admin.patch("/feedback/:id", requireRole("admin"), async (c) => {
|
admin.patch("/feedback/:id", requireRole("editor"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
GET /events/:id one event, full body, people
|
GET /events/:id one event, full body, people
|
||||||
GET /organizations list, ?kind=region|chapter|…
|
GET /organizations list, ?kind=region|chapter|…
|
||||||
GET /organizations/:id one organization's page
|
GET /organizations/:id one organization's page
|
||||||
|
GET /teams list, ?org=slug
|
||||||
|
GET /teams/:id one team's page
|
||||||
|
GET /awards list, ?org=slug
|
||||||
|
GET /awards/:id one award and its recipients
|
||||||
|
|
||||||
Organizations are one table, so they're one endpoint. A region
|
Organizations are one table, so they're one endpoint. A region
|
||||||
and a chapter differ by a handful of fields, which arrive under
|
and a chapter differ by a handful of fields, which arrive under
|
||||||
|
|
@ -12,9 +16,33 @@
|
||||||
list component be written once and pointed at any kind.
|
list component be written once and pointed at any kind.
|
||||||
|
|
||||||
Responses carry their fallbacks already resolved: an event's
|
Responses carry their fallbacks already resolved: an event's
|
||||||
`color` is its own or its host's, and `status` is derived from
|
`color` is its own or its first host's, and `status` is derived
|
||||||
the dates when it isn't set. Components read one field and don't
|
from the dates when it isn't set. Components read one field and
|
||||||
reimplement the rules.
|
don't reimplement the rules.
|
||||||
|
|
||||||
|
`event_type` is orthogonal to `section_id`: the section is which
|
||||||
|
band of the Retreats page an event belongs to, the type is what
|
||||||
|
kind of gathering it is. A region can run a class and a partner
|
||||||
|
can run a retreat, so neither implies the other and both ship on
|
||||||
|
every event.
|
||||||
|
|
||||||
|
An event's hosts are a list, in billing order, and each one is
|
||||||
|
either an organization or a person — `kind` says which, and
|
||||||
|
`org_kind` is there for the three organization routes. The
|
||||||
|
first is the one the colour and logo fell back to, which is why
|
||||||
|
order is data and not a display choice.
|
||||||
|
|
||||||
|
Two things the team and award routes deliberately don't do:
|
||||||
|
|
||||||
|
· /teams/:id carries no roster. /teams/:id/people in people.js
|
||||||
|
already serves it off v_org_leadership in the shape
|
||||||
|
PeopleTiles wants, and a second shaper here would be the same
|
||||||
|
visibility rules written twice, free to drift.
|
||||||
|
|
||||||
|
· /awards/:id carries no links and no content blocks. 'award'
|
||||||
|
is not in the owner_kind CHECK on either polymorphic table,
|
||||||
|
and widening it is a STRICT table rebuild. `description` is
|
||||||
|
the prose; the recipients are the page.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
@ -34,14 +62,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"];
|
||||||
|
|
||||||
const marks = (n) => Array(n).fill("?").join(",");
|
const marks = (n) => Array(n).fill("?").join(",");
|
||||||
|
|
||||||
|
/* ── Loaders ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Hosts for a batch of events, keyed by event id, in the order
|
||||||
|
they're billed. Shaped like loadLinks and loadBlocks so the list
|
||||||
|
route stays one query per collection rather than one per row.
|
||||||
|
|
||||||
|
Unpublished hosts are dropped here rather than in the view: the
|
||||||
|
admin reads the same view and needs to see them. An event whose
|
||||||
|
only host is unpublished comes back with an empty list, which is
|
||||||
|
the right answer — there is nobody to name and nowhere to link. */
|
||||||
|
function loadHosts(db, ids) {
|
||||||
|
const byEvent = new Map();
|
||||||
|
if (ids.length === 0) return byEvent;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT event_id, host_kind, host_id, host_name, host_org_kind
|
||||||
|
FROM v_event_hosts
|
||||||
|
WHERE event_id IN (${marks(ids.length)})
|
||||||
|
AND host_is_published = 1
|
||||||
|
ORDER BY event_id, sort_order, id`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byEvent.get(row.event_id) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
byEvent.set(row.event_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byEvent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shapers ───────────────────────────────────────────────── */
|
/* ── Shapers ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
function shapeEvent(row, links, cardBlocks) {
|
function shapeHost(row) {
|
||||||
|
return {
|
||||||
|
kind: row.host_kind, // 'organization' | 'person'
|
||||||
|
id: row.host_id,
|
||||||
|
name: row.host_name,
|
||||||
|
// Which of /regions, /chapters, /partners the slug belongs to.
|
||||||
|
// Null for a person, whose route needs no disambiguating.
|
||||||
|
org_kind: row.host_org_kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeEvent(row, links, cardBlocks, hosts = []) {
|
||||||
const { actions, instagram } = splitLinks(links);
|
const { actions, instagram } = splitLinks(links);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
section_id: row.section_id,
|
section_id: row.section_id,
|
||||||
|
event_type: row.event_type,
|
||||||
|
|
||||||
title: row.title,
|
title: row.title,
|
||||||
theme: row.theme,
|
theme: row.theme,
|
||||||
|
|
@ -63,9 +136,7 @@ function shapeEvent(row, links, cardBlocks) {
|
||||||
color: row.effective_color,
|
color: row.effective_color,
|
||||||
gradient: row.gradient,
|
gradient: row.gradient,
|
||||||
|
|
||||||
host: row.host_org_id
|
hosts: hosts.map(shapeHost),
|
||||||
? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
|
|
||||||
: null,
|
|
||||||
|
|
||||||
description: paragraphs(cardBlocks),
|
description: paragraphs(cardBlocks),
|
||||||
links: actions,
|
links: actions,
|
||||||
|
|
@ -124,6 +195,42 @@ function shapeLeader(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* teams.org_id is NOT NULL and every query below joins a published
|
||||||
|
organization, so `org` is never absent. */
|
||||||
|
function shapeTeam(row, links, cardBlocks) {
|
||||||
|
const { actions, socials, instagram } = splitLinks(links);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
tagline: row.tagline,
|
||||||
|
color: row.color,
|
||||||
|
logo: row.logo,
|
||||||
|
|
||||||
|
org: { id: row.org_id, name: row.org_name, kind: row.org_kind },
|
||||||
|
|
||||||
|
description: paragraphs(cardBlocks),
|
||||||
|
links: actions,
|
||||||
|
socials,
|
||||||
|
instagram,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* awards.org_id is nullable — an award can predate any decision
|
||||||
|
about which organization owns it — so `org` genuinely can be
|
||||||
|
null, and is also null when the awarding org is unpublished. */
|
||||||
|
function shapeAward(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
logo: row.logo,
|
||||||
|
org: row.org_id && row.org_name
|
||||||
|
? { id: row.org_id, name: row.org_name, kind: row.org_kind }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kind-specific details, batched ────────────────────────────
|
/* ── Kind-specific details, batched ────────────────────────────
|
||||||
Each of these runs a fixed number of queries for the whole list
|
Each of these runs a fixed number of queries for the whole list
|
||||||
rather than one per organization.
|
rather than one per organization.
|
||||||
|
|
@ -243,6 +350,84 @@ function attachLeadership(db, orgs) {
|
||||||
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
|
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sections that only an organization's own page wants ───────
|
||||||
|
Called from /organizations/:id and not from the list. A page
|
||||||
|
needs them; a card doesn't, and the listing shouldn't pay two
|
||||||
|
queries for something nothing renders.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Every team this organization has, including ones with nobody
|
||||||
|
currently filed under them. `leadership` already carries team_id
|
||||||
|
and team_name, so the page can group people without this — but
|
||||||
|
grouping alone would make an empty team invisible rather than
|
||||||
|
listed, which is the wrong answer for a team that exists. */
|
||||||
|
function attachTeams(db, orgs) {
|
||||||
|
const ids = orgs.map((o) => o.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, org_id, name, tagline, color, logo
|
||||||
|
FROM teams
|
||||||
|
WHERE org_id IN (${marks(ids.length)}) AND is_published = 1
|
||||||
|
ORDER BY sort_order, name`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
const byOrg = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byOrg.get(row.org_id);
|
||||||
|
const entry = {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
tagline: row.tagline,
|
||||||
|
color: row.color,
|
||||||
|
logo: row.logo,
|
||||||
|
};
|
||||||
|
if (list) list.push(entry);
|
||||||
|
else byOrg.set(row.org_id, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The awards this organization gives. awards has no is_published
|
||||||
|
column, so every row is public the moment it exists — see the
|
||||||
|
note in the route below. */
|
||||||
|
function attachAwards(db, orgs) {
|
||||||
|
const ids = orgs.map((o) => o.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.org_id, a.name, a.description, a.logo,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
|
||||||
|
FROM awards a
|
||||||
|
WHERE a.org_id IN (${marks(ids.length)})
|
||||||
|
ORDER BY a.sort_order, a.name`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
const byOrg = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byOrg.get(row.org_id);
|
||||||
|
const entry = {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
logo: row.logo,
|
||||||
|
recipient_count: row.recipient_count,
|
||||||
|
};
|
||||||
|
if (list) list.push(entry);
|
||||||
|
else byOrg.set(row.org_id, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const org of orgs) org.awards = byOrg.get(org.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Events ────────────────────────────────────────────────────
|
/* ── Events ────────────────────────────────────────────────────
|
||||||
Flat, with the section ids alongside. Retreats.tsx owns the
|
Flat, with the section ids alongside. Retreats.tsx owns the
|
||||||
section titles and colours and filters this list by section_id.
|
section titles and colours and filters this list by section_id.
|
||||||
|
|
@ -266,9 +451,15 @@ content.get("/events", (c) => {
|
||||||
const ids = rows.map((row) => row.id);
|
const ids = rows.map((row) => row.id);
|
||||||
const links = loadLinks(db, "event", ids);
|
const links = loadLinks(db, "event", ids);
|
||||||
const cards = loadBlocks(db, "event", ids, "card");
|
const cards = loadBlocks(db, "event", ids, "card");
|
||||||
|
const hosts = loadHosts(db, ids);
|
||||||
|
|
||||||
const events = rows.map((row) =>
|
const events = rows.map((row) =>
|
||||||
shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
|
shapeEvent(
|
||||||
|
row,
|
||||||
|
links.get(row.id) ?? [],
|
||||||
|
cards.get(row.id) ?? [],
|
||||||
|
hosts.get(row.id) ?? [],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return json(c, { sections, events });
|
return json(c, { sections, events });
|
||||||
|
|
@ -289,6 +480,7 @@ content.get("/events/:id", (c) => {
|
||||||
const links = loadLinks(db, "event", [id]).get(id) ?? [];
|
const links = loadLinks(db, "event", [id]).get(id) ?? [];
|
||||||
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
|
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
|
||||||
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
|
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
|
||||||
|
const hosts = loadHosts(db, [id]).get(id) ?? [];
|
||||||
|
|
||||||
const people = db
|
const people = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -297,8 +489,36 @@ content.get("/events/:id", (c) => {
|
||||||
)
|
)
|
||||||
.all(id);
|
.all(id);
|
||||||
|
|
||||||
|
// Awards presented at this event. person_awards.event_id is the
|
||||||
|
// only thing that records where a citation was read out, and an
|
||||||
|
// event page is the one place it reads as news rather than
|
||||||
|
// trivia.
|
||||||
|
const awards = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT pa.award_id, pa.awarded_on, pa.citation,
|
||||||
|
a.name AS award_name, a.logo AS award_logo,
|
||||||
|
pa.person_id, p.display_name, p.photo
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN awards a ON a.id = pa.award_id
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.event_id = ? AND pa.is_public = 1
|
||||||
|
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
|
||||||
|
)
|
||||||
|
.all(id)
|
||||||
|
.map((r) => ({
|
||||||
|
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
|
||||||
|
person: { id: r.person_id, name: r.display_name, photo: r.photo },
|
||||||
|
awarded_on: r.awarded_on,
|
||||||
|
citation: r.citation,
|
||||||
|
}));
|
||||||
|
|
||||||
return json(c, {
|
return json(c, {
|
||||||
event: { ...shapeEvent(row, links, cards), blocks: body, people },
|
event: {
|
||||||
|
...shapeEvent(row, links, cards, hosts),
|
||||||
|
blocks: body,
|
||||||
|
people,
|
||||||
|
awards,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -379,19 +599,179 @@ content.get("/organizations/:id", (c) => {
|
||||||
attachRegionDetails(db, one);
|
attachRegionDetails(db, one);
|
||||||
attachChapterDetails(db, one);
|
attachChapterDetails(db, one);
|
||||||
attachLeadership(db, one);
|
attachLeadership(db, one);
|
||||||
|
attachTeams(db, one);
|
||||||
|
attachAwards(db, one);
|
||||||
|
|
||||||
// Everything this organization is hosting or has hosted.
|
// Everything this organization is hosting or has hosted, whether
|
||||||
|
// on its own or alongside somebody else. Co-hosting counts: an
|
||||||
|
// event run jointly by two regions belongs on both pages.
|
||||||
organization.events = db
|
organization.events = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, title, date_label, effective_status AS status,
|
`SELECT e.id, e.title, e.date_label, e.event_type,
|
||||||
location_label, event_logo, effective_color AS color
|
e.effective_status AS status,
|
||||||
FROM v_events
|
e.location_label, e.event_logo,
|
||||||
WHERE host_org_id = ? AND is_published = 1
|
e.effective_color AS color
|
||||||
ORDER BY sort_order`,
|
FROM v_events e
|
||||||
|
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
|
||||||
|
WHERE e.is_published = 1
|
||||||
|
ORDER BY e.sort_order`,
|
||||||
)
|
)
|
||||||
.all(id);
|
.all(id);
|
||||||
|
|
||||||
return json(c, { organization });
|
return json(c, { organization });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Teams ─────────────────────────────────────────────────────
|
||||||
|
GET /teams every published team
|
||||||
|
GET /teams?org=mid-atlantic one organization's teams
|
||||||
|
GET /teams/:id one team's page
|
||||||
|
|
||||||
|
An unpublished organization hides its teams too, in both
|
||||||
|
routes. Without that join a retired chapter's board stays
|
||||||
|
reachable by URL after the chapter itself has gone.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
content.get("/teams", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const org = c.req.query("org");
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM teams t
|
||||||
|
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
|
||||||
|
WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""}
|
||||||
|
ORDER BY o.sort_order, t.sort_order, t.name`,
|
||||||
|
)
|
||||||
|
.all(...(org ? [org] : []));
|
||||||
|
|
||||||
|
const ids = rows.map((row) => row.id);
|
||||||
|
const links = loadLinks(db, "team", ids);
|
||||||
|
const cards = loadBlocks(db, "team", ids, "card");
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
teams: rows.map((row) =>
|
||||||
|
shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
content.get("/teams/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM teams t
|
||||||
|
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
|
||||||
|
WHERE t.id = ? AND t.is_published = 1`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) return c.json({ error: "No such team" }, 404);
|
||||||
|
|
||||||
|
const links = loadLinks(db, "team", [id]).get(id) ?? [];
|
||||||
|
const cards = loadBlocks(db, "team", [id], "card").get(id) ?? [];
|
||||||
|
const body = loadBlocks(db, "team", [id], "body").get(id) ?? [];
|
||||||
|
|
||||||
|
return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Awards ────────────────────────────────────────────────────
|
||||||
|
GET /awards every award
|
||||||
|
GET /awards?org=ngu awards a given organization gives
|
||||||
|
GET /awards/:id one award and who has received it
|
||||||
|
|
||||||
|
`awards` has no is_published column: an award is public the
|
||||||
|
moment somebody creates it, and there is no way to draft one.
|
||||||
|
That was fine while awards only appeared as a line on a
|
||||||
|
person's record; it is thinner ground now that each has a URL.
|
||||||
|
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
|
||||||
|
worth doing before this ships.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
content.get("/awards", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const org = c.req.query("org");
|
||||||
|
|
||||||
|
// The count has to apply exactly the visibility rules the detail
|
||||||
|
// route does, or a card will promise recipients the page then
|
||||||
|
// doesn't list.
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.*, o.name AS org_name, o.kind AS org_kind,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
|
||||||
|
FROM awards a
|
||||||
|
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
|
||||||
|
${org ? "WHERE a.org_id = ?" : ""}
|
||||||
|
ORDER BY a.sort_order, a.name`,
|
||||||
|
)
|
||||||
|
.all(...(org ? [org] : []));
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
awards: rows.map((row) => ({
|
||||||
|
...shapeAward(row),
|
||||||
|
recipient_count: row.recipient_count,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
content.get("/awards/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM awards a
|
||||||
|
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
|
||||||
|
WHERE a.id = ?`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) return c.json({ error: "No such award" }, 404);
|
||||||
|
|
||||||
|
// The event join is LEFT twice over: person_awards.event_id is
|
||||||
|
// ON DELETE SET NULL, and the event may since have been
|
||||||
|
// unpublished. A citation outlives the occasion it was read at.
|
||||||
|
//
|
||||||
|
// awarded_on DESC puts undated rows last in SQLite, which is the
|
||||||
|
// right end for a recipient nobody has dated yet.
|
||||||
|
const recipients = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT pa.person_id, pa.awarded_on, pa.citation,
|
||||||
|
p.display_name, p.photo, p.tagline,
|
||||||
|
e.id AS event_id, e.title AS event_title
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
|
||||||
|
WHERE pa.award_id = ? AND pa.is_public = 1
|
||||||
|
ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`,
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
award: {
|
||||||
|
...shapeAward(row),
|
||||||
|
recipients: recipients.map((r) => ({
|
||||||
|
id: r.person_id,
|
||||||
|
name: r.display_name,
|
||||||
|
photo: r.photo,
|
||||||
|
tagline: r.tagline,
|
||||||
|
awarded_on: r.awarded_on,
|
||||||
|
citation: r.citation,
|
||||||
|
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default content;
|
export default content;
|
||||||
|
|
|
||||||
217
server/src/routes/history.js
Normal file
217
server/src/routes/history.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HISTORY ROUTE — read-only, mounted under /api
|
||||||
|
|
||||||
|
GET /history every published timeline entry
|
||||||
|
|
||||||
|
v_timeline has already done the resolution: an entry with no title
|
||||||
|
of its own carries the referenced record's name, an entry with no
|
||||||
|
date carries the event's starts_on, and org_kind rides along
|
||||||
|
because /regions, /chapters and /partners are three routes and only
|
||||||
|
the database knows which a slug is.
|
||||||
|
|
||||||
|
What's left here is shaping, and three things the view can't do:
|
||||||
|
|
||||||
|
· rosters. A 'people' entry naming a team resolves through
|
||||||
|
v_org_leadership; one with an editorial list reads
|
||||||
|
timeline_entry_people. Both are batched, so the number of
|
||||||
|
queries doesn't grow with the number of entries.
|
||||||
|
|
||||||
|
· precision that outruns the date. An entry can hold '2012' with
|
||||||
|
precision 'day' — the admin doesn't stop you. Trusting that
|
||||||
|
pair would put the entry in a month node built from a month
|
||||||
|
that isn't there, so precision is capped at what the string
|
||||||
|
actually carries.
|
||||||
|
|
||||||
|
· entries with no date at all. They can't be placed on a rail, so
|
||||||
|
they're dropped rather than crashing the page, and counted so
|
||||||
|
the omission is visible rather than silent.
|
||||||
|
|
||||||
|
Filenames only, as everywhere else in this API. Where the images
|
||||||
|
live is the component's business.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
const history = new Hono();
|
||||||
|
|
||||||
|
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||||
|
|
||||||
|
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||||
|
|
||||||
|
const marks = (n) => Array(n).fill("?").join(",");
|
||||||
|
|
||||||
|
/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */
|
||||||
|
function precisionOfString(date) {
|
||||||
|
const parts = String(date).split("-");
|
||||||
|
if (parts.length >= 3) return "day";
|
||||||
|
if (parts.length === 2) return "month";
|
||||||
|
return "year";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK = { year: 0, month: 1, day: 2 };
|
||||||
|
|
||||||
|
/* The stored precision is a claim about how much to trust the date. It
|
||||||
|
can't be more precise than the date itself, and a row that claims
|
||||||
|
otherwise is a data error the page shouldn't have to survive. */
|
||||||
|
function effectivePrecision(stored, date) {
|
||||||
|
const actual = precisionOfString(date);
|
||||||
|
return RANK[stored] < RANK[actual] ? stored : actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeEntry(row, rosters) {
|
||||||
|
const precision = effectivePrecision(row.precision, row.effective_date);
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: String(row.id),
|
||||||
|
date: row.effective_date,
|
||||||
|
precision,
|
||||||
|
kind: row.kind,
|
||||||
|
title: row.effective_title,
|
||||||
|
featured: row.is_featured === 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row.effective_blurb) item.blurb = row.effective_blurb;
|
||||||
|
if (row.meta) item.meta = row.meta;
|
||||||
|
|
||||||
|
// An explicit link wins on the client too, but sending it only when
|
||||||
|
// set keeps "no override" distinguishable from "override to empty".
|
||||||
|
if (row.link_url) item.href = row.link_url;
|
||||||
|
|
||||||
|
if (row.ref_kind && row.ref_id) {
|
||||||
|
item.ref = { kind: row.ref_kind, id: row.ref_id };
|
||||||
|
// Only organizations need it, and only they have it.
|
||||||
|
if (row.org_kind) item.ref.orgKind = row.org_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.effective_logo && row.ref_kind) {
|
||||||
|
item.logo = { file: row.effective_logo, kind: row.ref_kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ref_kind === "team") {
|
||||||
|
item.team = {
|
||||||
|
id: row.ref_id,
|
||||||
|
name: row.team_name,
|
||||||
|
orgId: row.team_org_id ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = rosters.get(row.id);
|
||||||
|
if (people?.length) item.people = people;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.get("/history", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM v_timeline
|
||||||
|
WHERE is_published = 1
|
||||||
|
-- A standalone milestone reports null here and is unaffected.
|
||||||
|
AND (ref_is_published IS NULL OR ref_is_published = 1)
|
||||||
|
AND effective_date IS NOT NULL
|
||||||
|
ORDER BY effective_date DESC, sort_order, id`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// How many entries exist but can't be placed. Worth knowing about —
|
||||||
|
// an entry nobody gave a date to is invisible, and silence is how it
|
||||||
|
// stays that way.
|
||||||
|
const undated = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM v_timeline
|
||||||
|
WHERE is_published = 1 AND effective_date IS NULL`,
|
||||||
|
)
|
||||||
|
.get().n;
|
||||||
|
|
||||||
|
const rosters = loadRosters(db, rows);
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
items: rows.map((row) => shapeEntry(row, rosters)),
|
||||||
|
undated,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Rosters ───────────────────────────────────────────────────
|
||||||
|
Two queries total, whatever the number of entries. A team entry
|
||||||
|
reads the team's current public membership; anything else reads
|
||||||
|
the entry's own curated list.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function loadRosters(db, rows) {
|
||||||
|
const rosters = new Map();
|
||||||
|
|
||||||
|
const teamEntries = rows.filter(
|
||||||
|
(row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id,
|
||||||
|
);
|
||||||
|
const listEntries = rows.filter(
|
||||||
|
(row) => row.kind === "people" && row.ref_kind !== "team",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (teamEntries.length) {
|
||||||
|
const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))];
|
||||||
|
|
||||||
|
// v_org_leadership already decides who counts as current and
|
||||||
|
// public — affiliation still open, marked public, person
|
||||||
|
// published. Restating those conditions here is how they drift.
|
||||||
|
const members = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT team_id, person_id, display_name, photo, title
|
||||||
|
FROM v_org_leadership
|
||||||
|
WHERE team_id IN (${marks(teamIds.length)})
|
||||||
|
ORDER BY is_owner DESC, sort_order,
|
||||||
|
COALESCE(sort_name, display_name)`,
|
||||||
|
)
|
||||||
|
.all(...teamIds);
|
||||||
|
|
||||||
|
const byTeam = new Map();
|
||||||
|
for (const member of members) {
|
||||||
|
const list = byTeam.get(member.team_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: member.person_id,
|
||||||
|
name: member.display_name,
|
||||||
|
...(member.photo ? { photo: member.photo } : {}),
|
||||||
|
...(member.title ? { title: member.title } : {}),
|
||||||
|
});
|
||||||
|
byTeam.set(member.team_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of teamEntries) {
|
||||||
|
const list = byTeam.get(row.ref_id);
|
||||||
|
if (list) rosters.set(row.id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listEntries.length) {
|
||||||
|
const ids = listEntries.map((row) => row.id);
|
||||||
|
|
||||||
|
const listed = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT tep.entry_id, tep.person_id, tep.note,
|
||||||
|
p.display_name, p.photo
|
||||||
|
FROM timeline_entry_people tep
|
||||||
|
JOIN people p ON p.id = tep.person_id AND p.is_published = 1
|
||||||
|
WHERE tep.entry_id IN (${marks(ids.length)})
|
||||||
|
ORDER BY tep.entry_id, tep.sort_order`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
for (const person of listed) {
|
||||||
|
const list = rosters.get(person.entry_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: person.person_id,
|
||||||
|
name: person.display_name,
|
||||||
|
...(person.photo ? { photo: person.photo } : {}),
|
||||||
|
// The note is the person's standing in this entry, which is
|
||||||
|
// what `title` means on the client.
|
||||||
|
...(person.note ? { title: person.note } : {}),
|
||||||
|
});
|
||||||
|
rosters.set(person.entry_id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rosters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default history;
|
||||||
215
server/src/routes/panel.js
Normal file
215
server/src/routes/panel.js
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PANEL ROUTES — server/src/routes/panel.js
|
||||||
|
|
||||||
|
GET /api/admin/panel/overview
|
||||||
|
PATCH /api/admin/panel/users/:id role, is_active
|
||||||
|
DELETE /api/admin/panel/users/:id/sessions sign out everywhere
|
||||||
|
|
||||||
|
Everything here is superadmin-only, enforced once at the top
|
||||||
|
rather than per route — there's no read here that an ordinary
|
||||||
|
admin should have either. Account records and live session
|
||||||
|
counts are a different class of thing from content.
|
||||||
|
|
||||||
|
Two rules run through the writes, both about not locking
|
||||||
|
everyone out of the building:
|
||||||
|
|
||||||
|
* nobody edits their own role or active flag, so a misclick
|
||||||
|
can't demote the person making it;
|
||||||
|
* the last active superadmin can't be demoted or disabled.
|
||||||
|
|
||||||
|
Changing a role or disabling an account drops that person's
|
||||||
|
live sessions immediately, the same way admin-cli.js does.
|
||||||
|
Leaving a 30-day cookie valid after revoking the access it
|
||||||
|
represents is the whole point of having the button.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { requireAuth, requireRole, ROLES } from "../auth.js";
|
||||||
|
|
||||||
|
const panel = new Hono();
|
||||||
|
|
||||||
|
panel.use("*", requireAuth);
|
||||||
|
panel.use("*", requireRole("superadmin"));
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
/* The counts on the overview. Table name is a literal from this
|
||||||
|
list, never anything off the wire. */
|
||||||
|
const CONTENT_TABLES = [
|
||||||
|
["Events", "events"],
|
||||||
|
["Organizations", "organizations"],
|
||||||
|
["People", "people"],
|
||||||
|
["Teams", "teams"],
|
||||||
|
["Awards", "awards"],
|
||||||
|
["Timeline entries", "timeline_entries"],
|
||||||
|
["Feedback", "feedback"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── GET /api/admin/panel/overview ─────────────────────────────── */
|
||||||
|
|
||||||
|
panel.get("/overview", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const users = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.email, u.name, u.role, u.is_active,
|
||||||
|
u.created_at, u.last_login_at,
|
||||||
|
(SELECT COUNT(*) FROM sessions s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
AND s.expires_at > datetime('now')) AS sessions
|
||||||
|
FROM admin_users u
|
||||||
|
ORDER BY u.role DESC, u.email`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const content = CONTENT_TABLES.map(([label, table]) => ({
|
||||||
|
label,
|
||||||
|
count: count(db, table),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
system: {
|
||||||
|
schemaVersion: db.prepare("PRAGMA user_version").get().user_version,
|
||||||
|
dbPath: process.env.DB_PATH ?? null,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
platform: `${process.platform} ${process.arch}`,
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
|
||||||
|
sessions: count(db, "sessions", "expires_at > datetime('now')"),
|
||||||
|
roles: ROLES,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
users,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
NO_STORE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* A table that hasn't been created yet shouldn't take the whole
|
||||||
|
page down — the panel is where you go when something is wrong. */
|
||||||
|
function count(db, table, where) {
|
||||||
|
try {
|
||||||
|
const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`;
|
||||||
|
return db.prepare(sql).get().n;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */
|
||||||
|
|
||||||
|
panel.patch("/users/:id", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const me = c.get("user");
|
||||||
|
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
if (id === me.id) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "You can't change your own role or access. Ask another superadmin." },
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Expected a JSON body." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = db
|
||||||
|
.prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?")
|
||||||
|
.get(id);
|
||||||
|
if (!target) return c.json({ error: "No such account." }, 404);
|
||||||
|
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const losingSuper =
|
||||||
|
target.role === "superadmin" &&
|
||||||
|
((body.role !== undefined && body.role !== "superadmin") ||
|
||||||
|
(body.is_active !== undefined && Number(body.is_active) === 0));
|
||||||
|
|
||||||
|
if (losingSuper && activeSupers(db) <= 1) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "That's the last active superadmin. Promote someone else first." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.role !== undefined) {
|
||||||
|
if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422);
|
||||||
|
sets.push("role = ?");
|
||||||
|
params.push(body.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.is_active !== undefined) {
|
||||||
|
sets.push("is_active = ?");
|
||||||
|
params.push(Number(body.is_active) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run(
|
||||||
|
...params,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
// Whatever changed, the access they're holding no longer
|
||||||
|
// matches the row. Make them sign in again.
|
||||||
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ user: userRow(db, id) }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */
|
||||||
|
|
||||||
|
panel.delete("/users/:id/sessions", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id);
|
||||||
|
if (!target) return c.json({ error: "No such account." }, 404);
|
||||||
|
|
||||||
|
const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeSupers(db) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
|
||||||
|
)
|
||||||
|
.get().n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRow(db, id) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.email, u.name, u.role, u.is_active,
|
||||||
|
u.created_at, u.last_login_at,
|
||||||
|
(SELECT COUNT(*) FROM sessions s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
AND s.expires_at > datetime('now')) AS sessions
|
||||||
|
FROM admin_users u WHERE u.id = ?`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default panel;
|
||||||
26
src/App.tsx
26
src/App.tsx
|
|
@ -4,6 +4,7 @@ import Layout from "./components/Layout.tsx";
|
||||||
|
|
||||||
/*Libraries*/
|
/*Libraries*/
|
||||||
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
|
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
|
||||||
|
import RequireRole from "./pages/admin/RequireRole.tsx";
|
||||||
|
|
||||||
/*Primary Pages*/
|
/*Primary Pages*/
|
||||||
import Home from "./pages/Home.tsx";
|
import Home from "./pages/Home.tsx";
|
||||||
|
|
@ -11,14 +12,23 @@ import Retreats from "./pages/Retreats.tsx";
|
||||||
import Community from "./pages/Community.tsx";
|
import Community from "./pages/Community.tsx";
|
||||||
import Leadership from "./pages/Leadership.tsx";
|
import Leadership from "./pages/Leadership.tsx";
|
||||||
import Resources from "./pages/Resources.tsx";
|
import Resources from "./pages/Resources.tsx";
|
||||||
|
import History from "./pages/History.tsx";
|
||||||
|
|
||||||
/*Secondary Pages*/
|
/*Secondary Pages*/
|
||||||
import Feedback from "./pages/Feedback.tsx";
|
import Feedback from "./pages/Feedback.tsx";
|
||||||
import Giving from "./pages/Giving.tsx";
|
import Giving from "./pages/Giving.tsx";
|
||||||
|
|
||||||
|
/*Entity Pages*/
|
||||||
|
import EventDetail from './pages/EventDetail.tsx'
|
||||||
|
import OrganizationDetail from './pages/OrganizationDetail.tsx'
|
||||||
|
import TeamDetail from './pages/TeamDetail.tsx'
|
||||||
|
import AwardDetail from './pages/AwardDetail.tsx'
|
||||||
|
|
||||||
/*Admin Pages*/
|
/*Admin Pages*/
|
||||||
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
||||||
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
||||||
|
import AdminPanel from "./pages/admin/AdminPanel.tsx";
|
||||||
|
import AdminHome from "./pages/admin/AdminHome.tsx";
|
||||||
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
||||||
import EntityList from "./pages/admin/EntityList.tsx";
|
import EntityList from "./pages/admin/EntityList.tsx";
|
||||||
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
||||||
|
|
@ -36,10 +46,18 @@ export default function App() {
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="retreats" element={<Retreats />} />
|
<Route path="retreats" element={<Retreats />} />
|
||||||
|
<Route path="/events/:id" element={<EventDetail />} />
|
||||||
<Route path="community" element={<Community />} />
|
<Route path="community" element={<Community />} />
|
||||||
|
<Route path="/regions/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/chapters/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/partners/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/organizations/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/teams/:id" element={<TeamDetail />} />
|
||||||
|
<Route path="/awards/:id" element={<AwardDetail />} />
|
||||||
<Route path="leadership" element={<Leadership />} />
|
<Route path="leadership" element={<Leadership />} />
|
||||||
<Route path="resources" element={<Resources />} />
|
<Route path="resources" element={<Resources />} />
|
||||||
<Route path="feedback" element={<Feedback />} />
|
<Route path="history" element={<History />} />
|
||||||
|
<Route path="feedback" element={<Feedback />} />
|
||||||
<Route path="give" element={<Giving />} />
|
<Route path="give" element={<Giving />} />
|
||||||
<Route path="privacy" element={<Privacy />} />
|
<Route path="privacy" element={<Privacy />} />
|
||||||
<Route path="terms" element={<Terms />} />
|
<Route path="terms" element={<Terms />} />
|
||||||
|
|
@ -49,7 +67,11 @@ export default function App() {
|
||||||
<Route path="/admin/login" element={<AdminLogin />} />
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/feedback" replace />} />
|
<Route index element={<Navigate to="/admin/home" replace />} />
|
||||||
|
<Route path="home" element={<AdminHome />} />
|
||||||
|
<Route element={<RequireRole role="superadmin" />}>
|
||||||
|
<Route path="panel" element={<AdminPanel />} />
|
||||||
|
</Route>
|
||||||
<Route path="feedback" element={<AdminFeedback />} />
|
<Route path="feedback" element={<AdminFeedback />} />
|
||||||
<Route path=":entity" element={<EntityList />} />
|
<Route path=":entity" element={<EntityList />} />
|
||||||
<Route path=":entity/:id" element={<EntityEdit />} />
|
<Route path=":entity/:id" element={<EntityEdit />} />
|
||||||
|
|
|
||||||
189
src/components/ContentBlocks.tsx
Normal file
189
src/components/ContentBlocks.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
CONTENT BLOCKS
|
||||||
|
|
||||||
|
Renders the `body` slot of content_blocks. Events, organizations
|
||||||
|
and teams all own blocks and all render them the same way, so
|
||||||
|
this is written once and tinted by the caller's accent.
|
||||||
|
|
||||||
|
⚠ Assumes shape.js's loadBlocks returns rows carrying `type`,
|
||||||
|
`text`, `media`, `href` and an `items` array. If the field names
|
||||||
|
differ, this is the only file to fix — nothing else reads a
|
||||||
|
block.
|
||||||
|
|
||||||
|
An unknown `type` renders its text as a paragraph rather than
|
||||||
|
disappearing. A block somebody typed into the admin should be
|
||||||
|
visible even if the renderer hasn't caught up with it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { blockMedia } from '../lib/media.ts'
|
||||||
|
import type { ContentBlock } from '../lib/useContent.ts'
|
||||||
|
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
/** External if it has a scheme or starts with //; otherwise it's
|
||||||
|
* one of our own routes and should go through the router. */
|
||||||
|
const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)
|
||||||
|
|
||||||
|
function Anchor({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}) {
|
||||||
|
if (isExternal(href)) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer" className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link to={href} className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentBlocks({
|
||||||
|
blocks,
|
||||||
|
accent = '#138ba0',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
blocks?: ContentBlock[] | null
|
||||||
|
accent?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (!blocks?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${className}`}>
|
||||||
|
{blocks.map((block, index) => (
|
||||||
|
<Block key={block.id ?? index} block={block} accent={accent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({ block, accent }: { block: ContentBlock; accent: string }) {
|
||||||
|
const text = block.text ?? ''
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'heading':
|
||||||
|
return (
|
||||||
|
<h3 className="text-2xl font-bold" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'subheading':
|
||||||
|
return (
|
||||||
|
<h4 className="text-lg font-semibold" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</h4>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(block.items ?? []).map((item, index) => (
|
||||||
|
<li key={index} className="flex gap-3">
|
||||||
|
<span aria-hidden="true" style={{ color: accent }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
{item.url ? (
|
||||||
|
<Anchor href={item.url} className="underline" style={{ color: accent }}>
|
||||||
|
{item.text}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
item.text
|
||||||
|
)}
|
||||||
|
{item.detail && (
|
||||||
|
<span className="opacity-70"> — {item.detail}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{(block.items ?? [])
|
||||||
|
.filter((item) => item.url)
|
||||||
|
.map((item, index) => (
|
||||||
|
<Anchor
|
||||||
|
key={index}
|
||||||
|
href={item.url as string}
|
||||||
|
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'quote':
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
className="border-l-2 pl-5 text-lg italic"
|
||||||
|
style={{ borderColor: accent, color: BODY }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</blockquote>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'image': {
|
||||||
|
const src = blockMedia(block.media)
|
||||||
|
if (!src) return null
|
||||||
|
const img = (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
// A caption is a caption, not alt text — but an image with
|
||||||
|
// neither is decorative, and alt="" is the correct answer
|
||||||
|
// for that rather than a filename read aloud.
|
||||||
|
alt={text}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full rounded-lg"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<figure>
|
||||||
|
{block.href ? <Anchor href={block.href}>{img}</Anchor> : img}
|
||||||
|
{text && (
|
||||||
|
<figcaption className="mt-2 text-sm opacity-70" style={{ color: BODY }}>
|
||||||
|
{text}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'divider':
|
||||||
|
return <hr className="border-0 h-px" style={{ background: accent, opacity: 0.3 }} />
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
default:
|
||||||
|
if (!text) return null
|
||||||
|
return (
|
||||||
|
<p className="leading-relaxed" style={{ color: BODY }}>
|
||||||
|
{block.href ? (
|
||||||
|
<Anchor href={block.href} className="underline" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/components/PageState.tsx
Normal file
91
src/components/PageState.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PAGE STATE
|
||||||
|
|
||||||
|
The three ways a detail page can fail to be a detail page. All
|
||||||
|
four of them need the same thing, and it should be the same thing
|
||||||
|
— a visitor who hits a dead retreat link and a dead chapter link
|
||||||
|
shouldn't get two different pages.
|
||||||
|
|
||||||
|
Rendered inside PageShell so the chrome doesn't flicker in and
|
||||||
|
out between loading and loaded.
|
||||||
|
|
||||||
|
A 404 gets no retry button: the slug doesn't exist and trying
|
||||||
|
again won't change that. Anything else does, because a dropped
|
||||||
|
connection is the usual cause and one tap fixes it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PageShell from './PageShell.tsx'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function PageState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
notFound,
|
||||||
|
onRetry,
|
||||||
|
noun,
|
||||||
|
backTo,
|
||||||
|
backLabel,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
notFound: boolean
|
||||||
|
onRetry: () => void
|
||||||
|
/** Lowercase, as it appears mid-sentence: "retreat", "chapter". */
|
||||||
|
noun: string
|
||||||
|
backTo: string
|
||||||
|
backLabel: string
|
||||||
|
}) {
|
||||||
|
let title: string
|
||||||
|
let content: React.ReactNode
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
title = 'Not found'
|
||||||
|
content = (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<p style={{ color: BODY }}>
|
||||||
|
There’s no {noun} at this address. It may have been renamed, or taken
|
||||||
|
down.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={backTo}
|
||||||
|
className="mt-4 inline-block rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (error) {
|
||||||
|
title = 'Something went wrong'
|
||||||
|
content = (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<p style={{ color: '#b3261e' }}>Couldn’t load this {noun}. {error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-3 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
title = 'Loading…'
|
||||||
|
content = (
|
||||||
|
<p className="max-w-6xl mx-auto px-6" style={{ color: BODY }} role="status">
|
||||||
|
Loading this {noun}…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={title}
|
||||||
|
sections={[{ id: 'status', title: '', accent: TEAL, background: '#ffffff', content }]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,9 @@ const inputLocked =
|
||||||
/* ── Dotted paths ────────────────────────────────────────────── */
|
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function getPath(object, path) {
|
export function getPath(object, path) {
|
||||||
|
// An entity with no slug has no heading path either, and a missing
|
||||||
|
// path should read as "no value" rather than throwing on .split.
|
||||||
|
if (!path) return undefined;
|
||||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,17 @@
|
||||||
narrows the result to what it shows.
|
narrows the result to what it shows.
|
||||||
|
|
||||||
useEvents({ section: "national" }) one band
|
useEvents({ section: "national" }) one band
|
||||||
useEvents({ host: "northwest" }) a region's own events
|
useEvents({ host: "northwest" }) one host's events
|
||||||
useEvents({ status: "upcoming" }) a home page strip
|
useEvents({ status: "upcoming" }) a home page strip
|
||||||
|
useEvents({ type: "workshop" }) one kind, wherever it is
|
||||||
|
useEvents({ type: ["class", "workshop"] })
|
||||||
useEvents() everything
|
useEvents() everything
|
||||||
|
|
||||||
|
`section` and `type` are different questions and stack rather
|
||||||
|
than overlap: the section is which band of the page an event
|
||||||
|
belongs to, the type is what kind of gathering it is. A regional
|
||||||
|
class matches both { section: "regional" } and { type: "class" }.
|
||||||
|
|
||||||
Filtering here rather than in the query keeps the endpoint to
|
Filtering here rather than in the query keeps the endpoint to
|
||||||
one cached response. At a few dozen events that's the right
|
one cached response. At a few dozen events that's the right
|
||||||
trade; if the list ever runs to hundreds, move the filters into
|
trade; if the list ever runs to hundreds, move the filters into
|
||||||
|
|
@ -23,18 +30,30 @@ import { useResource } from "../lib/useResource.js";
|
||||||
|
|
||||||
const EMPTY = { events: [] };
|
const EMPTY = { events: [] };
|
||||||
|
|
||||||
export function useEvents({ section, host, status } = {}) {
|
export function useEvents({ section, host, status, type } = {}) {
|
||||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
||||||
|
|
||||||
const all = data?.events;
|
const all = data?.events;
|
||||||
|
|
||||||
|
/* An array prop is a new identity on every render, which would
|
||||||
|
restart the memo each time. Joining it gives the dependency
|
||||||
|
list something stable to compare. */
|
||||||
|
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
|
||||||
|
|
||||||
const events = useMemo(() => {
|
const events = useMemo(() => {
|
||||||
let list = all ?? [];
|
let list = all ?? [];
|
||||||
if (section) list = list.filter(e => e.section_id === section);
|
if (section) list = list.filter(e => e.section_id === section);
|
||||||
if (host) list = list.filter(e => e.host?.id === host);
|
// `host` is an organization or a person slug, and an event can
|
||||||
|
// have several of either — co-hosting puts one event on both
|
||||||
|
// hosts' lists, which is the point.
|
||||||
|
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
|
||||||
if (status) list = list.filter(e => e.status === status);
|
if (status) list = list.filter(e => e.status === status);
|
||||||
|
if (typeKey) {
|
||||||
|
const wanted = new Set(typeKey.split(","));
|
||||||
|
list = list.filter(e => wanted.has(e.event_type));
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [all, section, host, status]);
|
}, [all, section, host, status, typeKey]);
|
||||||
|
|
||||||
return { events, loading, error };
|
return { events, loading, error };
|
||||||
}
|
}
|
||||||
|
|
@ -52,3 +71,12 @@ export function splitByStatus(events = []) {
|
||||||
|
|
||||||
return { upcoming, past };
|
return { upcoming, past };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Which of the declared types a list actually contains, in
|
||||||
|
EVENT_TYPES order rather than whatever order the rows arrived
|
||||||
|
in. A section with one type has nothing to filter, which is what
|
||||||
|
lets the chip bar hide itself. */
|
||||||
|
export function typesPresent(events = [], declared = []) {
|
||||||
|
const seen = new Set(events.map(e => e.event_type));
|
||||||
|
return declared.filter(entry => seen.has(entry.id));
|
||||||
|
}
|
||||||
|
|
|
||||||
63
src/data/historyDecades.ts
Normal file
63
src/data/historyDecades.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* Decade headers for the history page.
|
||||||
|
*
|
||||||
|
* Not in the database on purpose. There are four of them, they change
|
||||||
|
* about never, and they're editorial voice rather than record — the same
|
||||||
|
* reasoning that keeps the map grid config in code. A table for four
|
||||||
|
* rows that nobody edits is a migration and an admin page for nothing.
|
||||||
|
*
|
||||||
|
* `preProgram` is what draws the gap: a dashed rail, hollow year dots,
|
||||||
|
* and the "before the program" marker. It lives on the decade rather
|
||||||
|
* than being a hardcoded year check, so the gap moves if the founding
|
||||||
|
* date is ever revised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DecadeMeta } from '../lib/timeline'
|
||||||
|
|
||||||
|
export const HISTORY_DECADES: DecadeMeta[] = [
|
||||||
|
{
|
||||||
|
decade: 2020,
|
||||||
|
title: 'The Age of Autonomy',
|
||||||
|
tagline: 'Growth, unprecedented support, returning to our roots',
|
||||||
|
blurb:
|
||||||
|
'Covid-19 sends everyone home for 2 years and from the resurgence comes a new iteration of the program',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 2010,
|
||||||
|
title: 'Millennials run the show',
|
||||||
|
tagline: 'A time of something',
|
||||||
|
blurb:
|
||||||
|
'Need to update what happened here, mostly documented on facebook we asumme',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 2000,
|
||||||
|
title: 'NGU - The new acronym',
|
||||||
|
tagline: 'Turn of the century forms a national explosion',
|
||||||
|
blurb:
|
||||||
|
'Unity\'s young adult program becomes Next Generation of Unity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1990,
|
||||||
|
title: 'Before NGU there was YAU',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1980,
|
||||||
|
title: 'YAU?',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1970,
|
||||||
|
title: 'YAU?',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
]
|
||||||
299
src/data/old-historyMock.ts
Normal file
299
src/data/old-historyMock.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
/**
|
||||||
|
* PLACEHOLDER DATA — delete this file once `GET /api/history` lands.
|
||||||
|
*
|
||||||
|
* Shaped exactly like the aggregation route's payload, so swapping it out
|
||||||
|
* is a two-line change in History.tsx.
|
||||||
|
*
|
||||||
|
* Note what is and isn't here. Entries that point at a record carry a
|
||||||
|
* `ref` and little else: no venue, no host name, no copy that also lives
|
||||||
|
* in `events`. The few that do carry a title are overriding it on
|
||||||
|
* purpose. Titles and blurbs below are invented scaffolding, not real NGU
|
||||||
|
* history — replace them before anyone sees this.
|
||||||
|
*
|
||||||
|
* Decade headers are not here — they live in historyDecades.ts and stay
|
||||||
|
* in code even after the API lands.
|
||||||
|
*
|
||||||
|
* Upcoming items are not flagged. `partitionByDate` decides what's
|
||||||
|
* upcoming by comparing dates to the wall clock, so nothing here needs
|
||||||
|
* editing as dates pass.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TimelineItem } from '../lib/timeline'
|
||||||
|
|
||||||
|
export const MOCK_ITEMS: TimelineItem[] = [
|
||||||
|
// ——— Upcoming (relative to the wall clock, not a flag) ———
|
||||||
|
{
|
||||||
|
id: 'tl-0001',
|
||||||
|
date: '2027-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference 2027',
|
||||||
|
meta: 'Unity Village, MO',
|
||||||
|
ref: { kind: 'event', id: 'summer-2027' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0002',
|
||||||
|
date: '2026-11-14',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Fall Regional Rally',
|
||||||
|
meta: 'Southeast',
|
||||||
|
ref: { kind: 'event', id: 'fall-rally-2026' },
|
||||||
|
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0003',
|
||||||
|
date: '2026-10-03',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Chapter Leads Intensive',
|
||||||
|
meta: 'Online',
|
||||||
|
ref: { kind: 'event', id: 'leads-intensive-2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2020s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0010',
|
||||||
|
date: '2026-03',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'organization',
|
||||||
|
title: 'Twentieth chapter chartered',
|
||||||
|
blurb:
|
||||||
|
'The first new charter in the region since 2017, started by three people who met at a rally two summers earlier.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'organization', id: 'boise', orgKind: 'chapter' },
|
||||||
|
logo: { file: 'boise.svg', kind: 'organization' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0011',
|
||||||
|
date: '2026-04-17',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Spring Regional Rally',
|
||||||
|
ref: { kind: 'event', id: 'spring-rally-2026' },
|
||||||
|
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0012',
|
||||||
|
date: '2026-01',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'people',
|
||||||
|
title: 'New leadership team seated',
|
||||||
|
blurb: 'A four-person exec on a two-year term, the first under the 2024 bylaws.',
|
||||||
|
ref: { kind: 'team', id: 'ngu-leadership' },
|
||||||
|
team: { id: 'ngu-leadership', name: 'Leadership Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||||
|
people: [
|
||||||
|
{ id: 'jane-doe', name: 'Jane Doe', title: 'Chair', photo: 'jane-doe.jpg' },
|
||||||
|
{ id: 'sam-ruiz', name: 'Sam Ruiz', title: 'Vice Chair', photo: 'sam-ruiz.jpg' },
|
||||||
|
{ id: 'ada-mensah', name: 'Ada Mensah', title: 'Secretary' },
|
||||||
|
{ id: 'tom-baird', name: 'Tom Baird', title: 'Treasurer', photo: 'tom-baird.jpg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0013',
|
||||||
|
date: '2025-12-28',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Winter Gathering',
|
||||||
|
ref: { kind: 'event', id: 'winter-gathering-2025' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0014',
|
||||||
|
date: '2025-07-19',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'Service Award',
|
||||||
|
meta: 'Presented to the Southeast chapter leads',
|
||||||
|
ref: { kind: 'award', id: 'service' },
|
||||||
|
logo: { file: 'service-award.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0015',
|
||||||
|
date: '2025-07-17',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
meta: 'Three days, 140 attendees',
|
||||||
|
ref: { kind: 'event', id: 'summer-2025' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0016',
|
||||||
|
date: '2025',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Photo archive digitized',
|
||||||
|
blurb: 'Roughly 4,000 images from 2003 onward, scanned by volunteers.',
|
||||||
|
href: 'https://archive.nextgenerationofunity.org',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0017',
|
||||||
|
date: '2024-09',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Bylaws rewritten',
|
||||||
|
blurb:
|
||||||
|
'Term limits, a defined handoff window, and the first written process for chartering a chapter.',
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0018',
|
||||||
|
date: '2024-07-11',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
ref: { kind: 'event', id: 'summer-2024' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0019',
|
||||||
|
date: '2024-07-13',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'Emerging Leader Award',
|
||||||
|
meta: 'First year the award was given',
|
||||||
|
ref: { kind: 'award', id: 'emerging-leader' },
|
||||||
|
logo: { file: 'emerging-leader.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0020',
|
||||||
|
date: '2022-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'First in-person rally since 2019',
|
||||||
|
blurb: 'Sixty-one people, most of whom had only ever met on a video call.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'event', id: 'return-rally-2022' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0021',
|
||||||
|
date: '2021-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Online Summer Intensive',
|
||||||
|
meta: 'Six sessions across two weeks',
|
||||||
|
ref: { kind: 'event', id: 'online-intensive-2021' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0022',
|
||||||
|
date: '2020-03',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'All gatherings suspended',
|
||||||
|
blurb: 'Weekly online rooms started the following week and ran for 118 weeks.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2010s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0030',
|
||||||
|
date: '2018-05',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'organization',
|
||||||
|
title: 'Eighth region recognized',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'organization', id: 'northwest', orgKind: 'region' },
|
||||||
|
logo: { file: 'northwest.svg', kind: 'organization' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0031',
|
||||||
|
date: '2018-07-20',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
ref: { kind: 'event', id: 'summer-2018' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0032',
|
||||||
|
date: '2016-02',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'people',
|
||||||
|
title: 'Retreat Team formed',
|
||||||
|
blurb:
|
||||||
|
'Programming had been whoever volunteered. This made it a standing team with a handoff.',
|
||||||
|
ref: { kind: 'team', id: 'ngu-retreat-team' },
|
||||||
|
team: { id: 'ngu-retreat-team', name: 'Retreat Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||||
|
people: [
|
||||||
|
{ id: 'marcus-hale', name: 'Marcus Hale', title: 'Founding lead', photo: 'marcus-hale.jpg' },
|
||||||
|
{ id: 'priya-nair', name: 'Priya Nair', photo: 'priya-nair.jpg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0033',
|
||||||
|
date: '2015-07',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'First Service Award presented',
|
||||||
|
blurb: 'Created to name the work that had been going unnamed for a decade.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'award', id: 'service' },
|
||||||
|
logo: { file: 'service-award.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0034',
|
||||||
|
date: '2015-02',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Shared calendar goes live',
|
||||||
|
blurb: 'Regions stop scheduling on top of each other.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0035',
|
||||||
|
date: '2012-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
meta: 'The year attendance first passed 100',
|
||||||
|
ref: { kind: 'event', id: 'summer-2012' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0036',
|
||||||
|
date: '2012',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Adopted the name Next Generation of Unity',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2000s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0040',
|
||||||
|
date: '2009',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Four regions drawn on a map for the first time',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0041',
|
||||||
|
date: '2005-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'The gathering becomes annual',
|
||||||
|
blurb: 'Before this it happened when someone had the energy to organize it.',
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0042',
|
||||||
|
date: '2002-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'First young adult retreat',
|
||||||
|
meta: 'Nineteen attendees',
|
||||||
|
blurb:
|
||||||
|
'Organized over six weeks at a borrowed retreat center. Everything else on this page follows from it.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'event', id: 'first-retreat-2002' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— Pre-program ———
|
||||||
|
{
|
||||||
|
id: 'tl-0050',
|
||||||
|
date: '1997',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Regional youth programs running independently',
|
||||||
|
blurb:
|
||||||
|
'Not NGU, and not connected to each other — but the people who started NGU came out of these.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -32,7 +32,7 @@ export function useOrganizations(kind) {
|
||||||
? `/organizations?kind=${encodeURIComponent(kind)}`
|
? `/organizations?kind=${encodeURIComponent(kind)}`
|
||||||
: "/organizations";
|
: "/organizations";
|
||||||
|
|
||||||
const { data, error, loading } = useResource(path, { fallback: EMPTY });
|
const { data, error, loading } = useResource(path);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
organizations: data?.organizations ?? [],
|
organizations: data?.organizations ?? [],
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [
|
||||||
{ path: "is_public", label: "Public", widget: "checkbox" },
|
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* The timeline panel that appears on an event or organization once the
|
||||||
|
box is ticked. Paths are prefixed with the extension key, exactly as
|
||||||
|
'region.scope' and 'private.notes' are.
|
||||||
|
|
||||||
|
Everything here is an override. Left blank, the history page falls
|
||||||
|
back to the record's own title, date and logo through v_timeline —
|
||||||
|
which is the point of referencing rather than copying. */
|
||||||
|
const timelineExtensionFields = (noun) => [
|
||||||
|
{
|
||||||
|
path: "timeline.occurred_on",
|
||||||
|
label: "Date on the timeline",
|
||||||
|
help: `Blank uses the ${noun}'s own date. Partial dates are fine: 2012, 2012-06`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "timeline.precision",
|
||||||
|
label: "Date precision",
|
||||||
|
widget: "select",
|
||||||
|
options: ["year", "month", "day"],
|
||||||
|
help: "How much of the date to trust. 'year' files it under 'Elsewhere in 2012'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "timeline.title",
|
||||||
|
label: "Title override",
|
||||||
|
full: true,
|
||||||
|
help: `Blank uses the ${noun}'s name`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "timeline.blurb",
|
||||||
|
label: "Blurb override",
|
||||||
|
widget: "textarea",
|
||||||
|
full: true,
|
||||||
|
help: `Blank uses the ${noun}'s tagline`,
|
||||||
|
},
|
||||||
|
{ path: "timeline.meta", label: "Secondary line", help: "Region, venue, recipient" },
|
||||||
|
{
|
||||||
|
path: "timeline.link_url",
|
||||||
|
label: "Link override",
|
||||||
|
help: `Blank links to the ${noun}'s own page`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "timeline.is_featured",
|
||||||
|
label: "Featured",
|
||||||
|
widget: "checkbox",
|
||||||
|
help: "Shown large, above the month list for its year",
|
||||||
|
},
|
||||||
|
{ path: "timeline.is_published", label: "Visible on the history page", widget: "checkbox" },
|
||||||
|
{ path: "timeline.sort_order", label: "Sort order", widget: "number" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The checkbox itself, plus the panel it gates. One entry in `groups`. */
|
||||||
|
const timelineGroup = (noun) => ({
|
||||||
|
legend: "Timeline",
|
||||||
|
note:
|
||||||
|
`Adds this ${noun} to the history page. Nothing is copied — the entry ` +
|
||||||
|
`reads this record, so editing it here updates the timeline too.`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "in_timeline",
|
||||||
|
label: "On the timeline",
|
||||||
|
widget: "checkbox",
|
||||||
|
help: "Show this on the history page",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelineDetailGroup = (noun) => ({
|
||||||
|
legend: "Timeline entry",
|
||||||
|
when: { path: "in_timeline", value: 1 },
|
||||||
|
note: "Every field here is optional. Blank means \u201cuse the record's own value\u201d.",
|
||||||
|
fields: timelineExtensionFields(noun),
|
||||||
|
});
|
||||||
|
|
||||||
/* The two collections every entity carries. */
|
/* The two collections every entity carries. */
|
||||||
const linksChild = {
|
const linksChild = {
|
||||||
key: "links",
|
key: "links",
|
||||||
|
|
@ -69,6 +141,43 @@ const linksChild = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Hosts. One row is one host, ordered, and each is either an
|
||||||
|
organization or a person — never both, which the CHECK on
|
||||||
|
event_hosts enforces and this form can only ask nicely about.
|
||||||
|
|
||||||
|
The first row supplies the logo and colour when the event sets
|
||||||
|
neither, so the order here is data rather than a display choice.
|
||||||
|
A person supplies neither: there is no colour on a person and a
|
||||||
|
headshot is not a logo, so a person-hosted event with no colour
|
||||||
|
of its own falls through to the section default. */
|
||||||
|
const hostsChild = {
|
||||||
|
key: "event_hosts",
|
||||||
|
label: "Hosts",
|
||||||
|
addLabel: "Add host",
|
||||||
|
title: (row, options) =>
|
||||||
|
options?.organizations?.find((o) => o.id === row.org_id)?.label ??
|
||||||
|
options?.people?.find((p) => p.id === row.person_id)?.label ??
|
||||||
|
"New host",
|
||||||
|
blank: { org_id: "", person_id: "" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "org_id",
|
||||||
|
label: "Organization",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "person_id",
|
||||||
|
label: "Person",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "people",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
help: "One or the other, not both. First host supplies the logo and colour.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const blocksChild = {
|
const blocksChild = {
|
||||||
key: "content_blocks",
|
key: "content_blocks",
|
||||||
label: "Content blocks",
|
label: "Content blocks",
|
||||||
|
|
@ -184,6 +293,8 @@ const organizations = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ legend: "Place", fields: PLACE_FIELDS },
|
{ legend: "Place", fields: PLACE_FIELDS },
|
||||||
|
timelineGroup("organization"),
|
||||||
|
timelineDetailGroup("organization"),
|
||||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -225,13 +336,19 @@ const events = {
|
||||||
list: {
|
list: {
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "title", label: "Title", primary: true },
|
{ key: "title", label: "Title", primary: true },
|
||||||
{ key: "section_id", label: "Section" },
|
{ key: "section_id", label: "Scope" },
|
||||||
|
{ key: "event_type", label: "Type" },
|
||||||
{ key: "date_label", label: "Dates" },
|
{ key: "date_label", label: "Dates" },
|
||||||
{ key: "status", label: "Status" },
|
{ key: "status", label: "Status" },
|
||||||
{ key: "is_published", label: "Live", widget: "bool" },
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
|
{ key: "section_id", label: "Scope", optionsFrom: "event_sections" },
|
||||||
|
{
|
||||||
|
key: "event_type",
|
||||||
|
label: "Type",
|
||||||
|
options: ["retreat", "class", "workshop", "meeting", "other"],
|
||||||
|
},
|
||||||
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
|
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
|
||||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
],
|
],
|
||||||
|
|
@ -241,20 +358,25 @@ const events = {
|
||||||
{
|
{
|
||||||
legend: "Identity",
|
legend: "Identity",
|
||||||
fields: [
|
fields: [
|
||||||
|
// The column is still section_id — the rows in event_sections
|
||||||
|
// are what changed, not the schema. Only the label moved,
|
||||||
|
// because "scope" is what the field has always meant and
|
||||||
|
// "section" described where it happened to be rendered.
|
||||||
{
|
{
|
||||||
path: "section_id",
|
path: "section_id",
|
||||||
label: "Section",
|
label: "Scope",
|
||||||
widget: "select",
|
widget: "select",
|
||||||
optionsFrom: "event_sections",
|
optionsFrom: "event_sections",
|
||||||
required: true,
|
required: true,
|
||||||
|
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "host_org_id",
|
path: "event_type",
|
||||||
label: "Host",
|
label: "Type",
|
||||||
widget: "select",
|
widget: "select",
|
||||||
optionsFrom: "organizations",
|
options: ["retreat", "class", "workshop", "meeting", "other"],
|
||||||
blankLabel: "— none —",
|
required: true,
|
||||||
help: "Supplies the logo and colour when this event sets neither",
|
help: "What kind of gathering. Independent of the scope",
|
||||||
},
|
},
|
||||||
{ path: "title", label: "Title", required: true },
|
{ path: "title", label: "Title", required: true },
|
||||||
{ path: "theme", label: "Theme" },
|
{ path: "theme", label: "Theme" },
|
||||||
|
|
@ -290,10 +412,13 @@ const events = {
|
||||||
{ path: "gradient", label: "Gradient", full: true },
|
{ path: "gradient", label: "Gradient", full: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
timelineGroup("event"),
|
||||||
|
timelineDetailGroup("event"),
|
||||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||||
],
|
],
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
|
hostsChild,
|
||||||
{
|
{
|
||||||
key: "event_people",
|
key: "event_people",
|
||||||
label: "People at this event",
|
label: "People at this event",
|
||||||
|
|
@ -600,7 +725,160 @@ const awards = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
|
/* ── Timeline ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The only entity with no slug: the table assigns the id, because an
|
||||||
|
// entry referencing an event has no name of its own and one gets
|
||||||
|
// created every time somebody ticks a checkbox. `idKind: "auto"` tells
|
||||||
|
// EntityEdit to show the id rather than ask for it.
|
||||||
|
//
|
||||||
|
// Most rows here are created from an event or organization page, not
|
||||||
|
// this one. What this page is for: hand-authored milestones with no
|
||||||
|
// record behind them, 'people' entries about a team forming, and fixing
|
||||||
|
// up the entries the checkboxes made.
|
||||||
|
const timeline = {
|
||||||
|
key: "timeline",
|
||||||
|
label: "Timeline",
|
||||||
|
singular: "entry",
|
||||||
|
idLabel: "Entry",
|
||||||
|
idKind: "auto",
|
||||||
|
titleFrom: "title",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "Title", primary: true },
|
||||||
|
{ key: "occurred_on", label: "Date" },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "ref_id", label: "References" },
|
||||||
|
{ key: "is_featured", label: "Featured", widget: "bool" },
|
||||||
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: "kind",
|
||||||
|
label: "Kind",
|
||||||
|
options: ["milestone", "event", "organization", "award", "people"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ref_kind",
|
||||||
|
label: "References",
|
||||||
|
options: ["event", "organization", "award", "person", "team"],
|
||||||
|
},
|
||||||
|
{ key: "is_featured", label: "Featured", options: [["1", "Featured"], ["0", "Normal"]] },
|
||||||
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "What this is",
|
||||||
|
note:
|
||||||
|
"An entry either points at a record or stands on its own. " +
|
||||||
|
"Pointing at one means its title, date and logo come from that " +
|
||||||
|
"record \u2014 nothing is copied, so editing the record updates this.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "kind",
|
||||||
|
label: "Kind",
|
||||||
|
widget: "select",
|
||||||
|
options: ["milestone", "event", "organization", "award", "people"],
|
||||||
|
required: true,
|
||||||
|
help: "Drives the marker and the layout. 'people' renders a roster",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "ref_kind",
|
||||||
|
label: "Points at",
|
||||||
|
widget: "select",
|
||||||
|
options: ["event", "organization", "award", "person", "team"],
|
||||||
|
blankLabel: "\u2014 nothing, this stands alone \u2014",
|
||||||
|
help: "Changing this leaves the record below orphaned \u2014 pick a new one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "ref_id",
|
||||||
|
label: "Record",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "timeline_refs",
|
||||||
|
blankLabel: "\u2014 none \u2014",
|
||||||
|
// One flat list of every referenceable row, narrowed to the
|
||||||
|
// kind chosen above. Five dropdowns of which four are always
|
||||||
|
// wrong would be worse.
|
||||||
|
filterBy: (option, row) => option.kind === row.ref_kind,
|
||||||
|
help: "Only records of the kind chosen above",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "When",
|
||||||
|
note:
|
||||||
|
"Blank takes the referenced record's own date. Precision is what " +
|
||||||
|
"says how much of the date to believe \u2014 a backfilled entry that " +
|
||||||
|
"only knows the year should say so.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "occurred_on",
|
||||||
|
label: "Date",
|
||||||
|
help: "2012, 2012-06 or 2012-06-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "precision",
|
||||||
|
label: "Precision",
|
||||||
|
widget: "select",
|
||||||
|
options: ["year", "month", "day"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Text",
|
||||||
|
note: "All optional. Blank uses the referenced record's own wording.",
|
||||||
|
fields: [
|
||||||
|
{ path: "title", label: "Title", full: true },
|
||||||
|
{ path: "blurb", label: "Blurb", widget: "textarea", full: true },
|
||||||
|
{ path: "meta", label: "Secondary line", help: "Region, venue, recipient" },
|
||||||
|
{ path: "link_url", label: "Link", help: "Blank links to the record's own page" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Publishing",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "is_featured",
|
||||||
|
label: "Featured",
|
||||||
|
widget: "checkbox",
|
||||||
|
help: "Shown large, above the month list for its year",
|
||||||
|
},
|
||||||
|
{ path: "is_published", label: "Published", widget: "checkbox" },
|
||||||
|
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "people",
|
||||||
|
label: "People",
|
||||||
|
addLabel: "Add person",
|
||||||
|
note:
|
||||||
|
"For an entry about people. A 'people' entry pointing at a team " +
|
||||||
|
"already shows that team's members \u2014 this is for the cases where " +
|
||||||
|
"the list is editorial rather than structural.",
|
||||||
|
title: (row, options) =>
|
||||||
|
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
|
||||||
|
blank: { person_id: "" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "person_id",
|
||||||
|
label: "Person",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "people",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ path: "note", label: "Note", help: "Founding lead, first chair" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
|
||||||
|
|
||||||
export function slugify(value) {
|
export function slugify(value) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
|
|
|
||||||
46
src/lib/eventTypes.ts
Normal file
46
src/lib/eventTypes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT TYPES
|
||||||
|
|
||||||
|
The client half of the CHECK on events.event_type. Order here is
|
||||||
|
display order — the filter chips read it straight off this array,
|
||||||
|
so moving a line moves a chip.
|
||||||
|
|
||||||
|
What this is not: event_sections. A section owns presentation —
|
||||||
|
Retreats.tsx keys its title, accent and background on the id, so
|
||||||
|
an unrecognised section_id makes an event vanish with no error,
|
||||||
|
which is why that one is a real table with a real foreign key. A
|
||||||
|
type carries no presentation of its own and an unknown value
|
||||||
|
renders as its own name, so a CHECK is enough.
|
||||||
|
|
||||||
|
Adding a type is three edits: the CHECK in a migration, the enum
|
||||||
|
in both descriptor halves, and this list. Adding it here alone
|
||||||
|
means the site offers a filter the database will refuse to store.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other'
|
||||||
|
|
||||||
|
export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [
|
||||||
|
{ id: 'retreat', label: 'Retreat', plural: 'Retreats' },
|
||||||
|
{ id: 'class', label: 'Class', plural: 'Classes' },
|
||||||
|
{ id: 'workshop', label: 'Workshop', plural: 'Workshops' },
|
||||||
|
{ id: 'meeting', label: 'Meeting', plural: 'Meetings' },
|
||||||
|
{ id: 'other', label: 'Other', plural: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id)
|
||||||
|
|
||||||
|
const BY_ID = new Map<string, { label: string; plural: string }>(
|
||||||
|
EVENT_TYPES.map((entry) => [entry.id as string, entry]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const capitalize = (word: string) =>
|
||||||
|
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||||
|
|
||||||
|
/* A value the CHECK has gained since this file was written renders
|
||||||
|
as itself rather than vanishing — the same rule EventDetail's
|
||||||
|
ROLE_ORDER follows for billing roles. */
|
||||||
|
export const eventTypeLabel = (id?: string | null): string =>
|
||||||
|
(id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '')
|
||||||
|
|
||||||
|
export const eventTypePlural = (id?: string | null): string =>
|
||||||
|
(id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '')
|
||||||
174
src/lib/hrefs.ts
Normal file
174
src/lib/hrefs.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PUBLIC HREFS
|
||||||
|
|
||||||
|
One place that turns a record into a URL. The API deliberately
|
||||||
|
never sends paths — it sends `{ kind, id }` and, for an
|
||||||
|
organization, the `org_kind` that decides which of the three
|
||||||
|
routes a slug belongs to. Deciding that in each component is how
|
||||||
|
/regions/x and /chapters/x end up both existing for the same
|
||||||
|
record, and how the history timeline ended up emitting /event/:id
|
||||||
|
while the router only knew /retreats/:id.
|
||||||
|
|
||||||
|
timelineRefs.ts now delegates here rather than keeping its own
|
||||||
|
table, so there is one answer to "where does an event live" and
|
||||||
|
changing it is changing EVENT_BASE below.
|
||||||
|
|
||||||
|
navConfig.js stays the source of truth for the *nav*: these are
|
||||||
|
record routes, which never appear in it.
|
||||||
|
|
||||||
|
── On missing ids ──
|
||||||
|
Every builder takes an id the caller believed it had. When it
|
||||||
|
doesn't, the old behaviour was to interpolate the string
|
||||||
|
"undefined" into a path, render a link to it, mount a page and
|
||||||
|
fetch /api/organizations/undefined — four steps between the
|
||||||
|
mistake and any sign of it, none of which name the component
|
||||||
|
that made it.
|
||||||
|
|
||||||
|
Now the id is checked here. In dev that's a console.error with a
|
||||||
|
stack trace pointing at the caller; in production the path still
|
||||||
|
comes out, because a broken link beats a crashed page, and
|
||||||
|
useResource refuses to fetch it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
|
||||||
|
|
||||||
|
/* A region, a chapter and a partner read as different things to a
|
||||||
|
visitor even though they are one table, so they get one route
|
||||||
|
each. 'national' is NGU itself — one record, no listing to sit
|
||||||
|
under, so it falls through to the generic path. */
|
||||||
|
const ORG_BASE: Record<string, string> = {
|
||||||
|
region: '/regions',
|
||||||
|
chapter: '/chapters',
|
||||||
|
partner: '/partners',
|
||||||
|
national: '/organizations',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical base for an event page.
|
||||||
|
*
|
||||||
|
* /events/:id, not /retreats/:id. The listing page is called
|
||||||
|
* Retreats because that's what NGU calls the gatherings it hosts,
|
||||||
|
* but the records are `events`, the API route is /api/events, and
|
||||||
|
* plenty of them — partner events, conferences — aren't retreats
|
||||||
|
* at all. Naming the record route after one page's editorial
|
||||||
|
* framing would have been wrong the first time a non-retreat got
|
||||||
|
* its own page.
|
||||||
|
*
|
||||||
|
* Nothing redirects from /retreats/:id, because nothing ever
|
||||||
|
* linked there. */
|
||||||
|
export const EVENT_BASE = '/events'
|
||||||
|
|
||||||
|
const DEV = Boolean((import.meta as any)?.env?.DEV)
|
||||||
|
|
||||||
|
/* Router params arrive as strings, so an id that has already been
|
||||||
|
through a template literal shows up as the literal word. Those
|
||||||
|
are as broken as a genuine null. */
|
||||||
|
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
|
||||||
|
|
||||||
|
export const isBadId = (id: unknown): boolean =>
|
||||||
|
id == null || BAD.has(String(id))
|
||||||
|
|
||||||
|
function checkId(id: unknown, what: string): string {
|
||||||
|
if (!isBadId(id)) return String(id)
|
||||||
|
|
||||||
|
if (DEV) {
|
||||||
|
// console.error rather than warn: this is always a bug, and the
|
||||||
|
// stack is the whole point — it names the component that passed
|
||||||
|
// nothing.
|
||||||
|
console.error(
|
||||||
|
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
|
||||||
|
`The link it returns will 404. Caller:`,
|
||||||
|
new Error('hrefs: missing id').stack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API path for one record, or null when the id isn't usable.
|
||||||
|
*
|
||||||
|
* The mirror image of the builders below: they make the URL a
|
||||||
|
* visitor sees, this makes the URL the client fetches, and both
|
||||||
|
* have to agree about what counts as an id.
|
||||||
|
*
|
||||||
|
* It lives here rather than next to the hook that calls it because
|
||||||
|
* it is a path, and paths are this file's job — and because a
|
||||||
|
* component that interpolates a missing route param produces the
|
||||||
|
* literal string "undefined", which the server cannot tell apart
|
||||||
|
* from a slug somebody genuinely typed. It answers 404 either way,
|
||||||
|
* and the log fills with GET /api/organizations/undefined with
|
||||||
|
* nothing to say where it came from.
|
||||||
|
*
|
||||||
|
* Returning null costs a round trip and turns a mystery 404 into
|
||||||
|
* the not-found page, which is what a visitor should see anyway.
|
||||||
|
*/
|
||||||
|
export function detailPath(base: string, id?: string | null): string | null {
|
||||||
|
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventHref = (id?: string | null) =>
|
||||||
|
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
|
||||||
|
|
||||||
|
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
|
||||||
|
|
||||||
|
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
|
||||||
|
|
||||||
|
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
|
||||||
|
|
||||||
|
export function orgHref(id?: string | null, kind?: string | null): string {
|
||||||
|
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For anything holding a polymorphic reference — timeline entries,
|
||||||
|
content blocks — where the kind arrives as data rather than being
|
||||||
|
known at the call site.
|
||||||
|
|
||||||
|
Returns null for a kind with no page AND for a reference with no
|
||||||
|
id, so the caller renders plain text instead of a dead link.
|
||||||
|
This is the one the timeline wants: an entry whose ref didn't
|
||||||
|
resolve should read as text, not as a link to nowhere. */
|
||||||
|
export function refHref(
|
||||||
|
kind: string | null | undefined,
|
||||||
|
id: string | null | undefined,
|
||||||
|
orgKind?: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!kind || isBadId(id)) return null
|
||||||
|
switch (kind) {
|
||||||
|
case 'event':
|
||||||
|
return eventHref(id)
|
||||||
|
case 'organization':
|
||||||
|
return orgHref(id, orgKind)
|
||||||
|
case 'team':
|
||||||
|
return teamHref(id)
|
||||||
|
case 'award':
|
||||||
|
return awardHref(id)
|
||||||
|
case 'person':
|
||||||
|
return personHref(id)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What to call the kind in a breadcrumb or a back link. */
|
||||||
|
export const ORG_KIND_LABEL: Record<string, string> = {
|
||||||
|
national: 'Next Generation of Unity',
|
||||||
|
region: 'Region',
|
||||||
|
chapter: 'Chapter',
|
||||||
|
partner: 'Partner organization',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where "back" goes from a record page. A chapter belongs to
|
||||||
|
/community, a retreat to /retreats. */
|
||||||
|
export function orgListHref(kind?: string | null): { to: string; label: string } {
|
||||||
|
switch (kind) {
|
||||||
|
case 'region':
|
||||||
|
return { to: '/community#local', label: 'All regions' }
|
||||||
|
case 'chapter':
|
||||||
|
return { to: '/community#local', label: 'All chapters' }
|
||||||
|
case 'partner':
|
||||||
|
return { to: '/community#partners', label: 'All partner organizations' }
|
||||||
|
default:
|
||||||
|
return { to: '/community', label: 'Community' }
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/lib/media.ts
Normal file
77
src/lib/media.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
MEDIA PATHS
|
||||||
|
|
||||||
|
Every image field in the API is a bare filename — "where the
|
||||||
|
images live is the component's business", as history.js puts it.
|
||||||
|
This is that business, in one file, so moving a directory is one
|
||||||
|
edit rather than a grep.
|
||||||
|
|
||||||
|
⚠ Only two of these directories are confirmed by the admin help
|
||||||
|
text: people/ and event-logos/. The other three are a guess at
|
||||||
|
your convention. Check public/ and fix them here — nothing else
|
||||||
|
references the paths.
|
||||||
|
|
||||||
|
A value that already looks like a path or a URL is returned
|
||||||
|
untouched, so a hand-written entry can point anywhere.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const ABSOLUTE = /^(https?:|\/|data:)/
|
||||||
|
|
||||||
|
function inDir(dir: string) {
|
||||||
|
return (file?: string | null): string | null => {
|
||||||
|
if (!file) return null
|
||||||
|
if (ABSOLUTE.test(file)) return file
|
||||||
|
return `${dir}/${file}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const personPhoto = inDir('/people') // confirmed
|
||||||
|
export const eventLogo = inDir('/event-logos') // confirmed
|
||||||
|
export const orgLogo = inDir('/org-logos') // ⚠ guess
|
||||||
|
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
||||||
|
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
||||||
|
|
||||||
|
/* content_blocks.media, which can be an image on any owner's page,
|
||||||
|
so it can't share a per-entity directory. */
|
||||||
|
export const blockMedia = inDir('/media') // ⚠ guess
|
||||||
|
|
||||||
|
/* ── By record kind ────────────────────────────────────────────
|
||||||
|
The timeline sends `logo: { file, kind }` rather than a path,
|
||||||
|
because v_timeline COALESCEs across five tables and only the
|
||||||
|
ref_kind says which one the filename came from.
|
||||||
|
|
||||||
|
⚠ Three of these directories are the guesses above. Your current
|
||||||
|
timelineRefs.ts already has the real ones — timeline logos render
|
||||||
|
today — so copy them into the map above and delete this note.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const BY_KIND: Record<string, (file?: string | null) => string | null> = {
|
||||||
|
event: eventLogo,
|
||||||
|
organization: orgLogo,
|
||||||
|
team: teamLogo,
|
||||||
|
award: awardLogo,
|
||||||
|
person: personPhoto,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoForKind(
|
||||||
|
kind?: string | null,
|
||||||
|
file?: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!file) return null
|
||||||
|
// An unrecognised kind still renders: a filename with no home
|
||||||
|
// directory is a bug, but a broken <img> says so louder than a
|
||||||
|
// silently absent one.
|
||||||
|
return (BY_KIND[kind ?? ''] ?? blockMedia)(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initials for a photo that is missing or fails to load. Same
|
||||||
|
two-word rule PeopleTiles uses. */
|
||||||
|
export function initials(name = ''): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word[0] || '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
57
src/lib/roles.ts
Normal file
57
src/lib/roles.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ROLES — src/lib/roles.ts
|
||||||
|
|
||||||
|
The same ladder as server/src/auth.js, and it has to stay the
|
||||||
|
same ladder. This copy exists to decide what to draw; the
|
||||||
|
server's copy decides what's allowed. If they ever disagree the
|
||||||
|
worst case is a button that 403s, which is the right way round
|
||||||
|
for them to fail.
|
||||||
|
|
||||||
|
Components should ask canDelete(user), not
|
||||||
|
user.role === "admin". The second form is what silently locked
|
||||||
|
superadmins out of saving when the third role went in: an
|
||||||
|
equality check against a ladder is a bug waiting for the next
|
||||||
|
role to be added, and there's now a fourth.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const;
|
||||||
|
|
||||||
|
export type Role = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export const ROLE_RANK: Record<Role, number> = {
|
||||||
|
viewer: 1,
|
||||||
|
editor: 2,
|
||||||
|
admin: 3,
|
||||||
|
superadmin: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
viewer: "Viewer",
|
||||||
|
editor: "Editor",
|
||||||
|
admin: "Admin",
|
||||||
|
superadmin: "Superadmin",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Each line is what that role adds to the one above it in the
|
||||||
|
list. Read top to bottom, they describe the whole ladder. */
|
||||||
|
export const ROLE_NOTES: Record<Role, string> = {
|
||||||
|
viewer: "Can read everything in the CMS and change nothing.",
|
||||||
|
editor: "Can create and update records. Can't delete anything.",
|
||||||
|
admin: "Can delete records, including feedback.",
|
||||||
|
superadmin: "Can manage accounts, roles and sessions.",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaybeUser = { role?: string | null } | null | undefined;
|
||||||
|
|
||||||
|
/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */
|
||||||
|
export function atLeast(user: MaybeUser, role: Role): boolean {
|
||||||
|
const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0;
|
||||||
|
return have >= ROLE_RANK[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Named for the capability rather than the rank, so call sites
|
||||||
|
read as intent and a future reshuffle of the ladder is one edit
|
||||||
|
here rather than a search for every comparison. */
|
||||||
|
export const canWrite = (user: MaybeUser) => atLeast(user, "editor");
|
||||||
|
export const canDelete = (user: MaybeUser) => atLeast(user, "admin");
|
||||||
|
export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin");
|
||||||
390
src/lib/timeline.ts
Normal file
390
src/lib/timeline.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
/**
|
||||||
|
* Timeline types + grouping.
|
||||||
|
*
|
||||||
|
* This is the contract between the future `GET /api/history` route and the
|
||||||
|
* history page.
|
||||||
|
*
|
||||||
|
* ── Reference, don't duplicate ─────────────────────────────────────────
|
||||||
|
* An entry is a *pointer* to a record plus an optional narrative override.
|
||||||
|
* When the admin panel's "add to timeline" button fires on an event, it
|
||||||
|
* writes a row holding the event's id and nothing else; title, logo and
|
||||||
|
* date are read back from `events` at query time. Editing the event
|
||||||
|
* therefore edits the timeline, and there is no second copy to drift.
|
||||||
|
*
|
||||||
|
* Hand-authored entries — "bylaws rewritten", "the gathering becomes
|
||||||
|
* annual" — carry no ref and supply their own title and blurb. An entry
|
||||||
|
* may also do both: reference an event but override its title, for when
|
||||||
|
* the timeline wants to say something the event card doesn't.
|
||||||
|
*
|
||||||
|
* ── What the server resolves, and what it doesn't ──────────────────────
|
||||||
|
* The server resolves *data*: title, date, logo filename, the members of
|
||||||
|
* a referenced team. It does not resolve *routes* or *asset paths* —
|
||||||
|
* those are presentation, and live in `timelineRefs.ts` so React Router
|
||||||
|
* and the public/ layout stay the frontend's business.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DatePrecision = 'year' | 'month' | 'day'
|
||||||
|
|
||||||
|
/** What an entry is about. Drives the marker and the body layout. */
|
||||||
|
export type TimelineKind =
|
||||||
|
| 'milestone' // free-standing narrative, no record behind it
|
||||||
|
| 'event'
|
||||||
|
| 'organization'
|
||||||
|
| 'award'
|
||||||
|
| 'people' // a team forming, someone joining one
|
||||||
|
|
||||||
|
/** Tables an entry can point at. Mirrors the polymorphic owner_kind
|
||||||
|
* pattern already used by content_blocks and links. */
|
||||||
|
export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team'
|
||||||
|
|
||||||
|
export type TimelineRef = {
|
||||||
|
kind: RefKind
|
||||||
|
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename plus the table it came from; the directory is derived
|
||||||
|
* frontend-side, because asset layout is not database business. */
|
||||||
|
export type TimelineLogo = {
|
||||||
|
file: string
|
||||||
|
kind: RefKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person as they appear in a 'people' entry. Resolved server-side,
|
||||||
|
* whether the entry named a team or listed people directly. */
|
||||||
|
export type PersonRef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** people.photo — filename only. */
|
||||||
|
photo?: string
|
||||||
|
/** Their affiliation title at the time, if it's worth printing. */
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamRef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
orgId?: string
|
||||||
|
orgName?: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineItem = {
|
||||||
|
/** The timeline row's own id, not the referenced record's. */
|
||||||
|
id: string
|
||||||
|
/** "2014" | "2014-06" | "2014-06-12" */
|
||||||
|
date: string
|
||||||
|
/** How much of `date` is trustworthy. Authoritative — a backfilled row
|
||||||
|
* may hold a full date while only the year is actually known. */
|
||||||
|
precision: DatePrecision
|
||||||
|
kind: TimelineKind
|
||||||
|
|
||||||
|
/** Falls back to the referenced record's own name when the row has no
|
||||||
|
* title of its own. Resolved server-side. */
|
||||||
|
title: string
|
||||||
|
blurb?: string
|
||||||
|
/** Secondary line: host org, region, venue, recipient. */
|
||||||
|
meta?: string
|
||||||
|
featured?: boolean
|
||||||
|
|
||||||
|
/** The record this points at. Absent for free-standing milestones. */
|
||||||
|
ref?: TimelineRef
|
||||||
|
/** Explicit link override. Absent → derived from `ref`. Every kind can
|
||||||
|
* carry one; event/organization/award fall back to their own page. */
|
||||||
|
href?: string
|
||||||
|
|
||||||
|
logo?: TimelineLogo
|
||||||
|
|
||||||
|
/** kind === 'people': who the entry is about. Populated from the named
|
||||||
|
* team's current members, or from an explicit person list. */
|
||||||
|
people?: PersonRef[]
|
||||||
|
/** Set when the entry named a team rather than loose people. */
|
||||||
|
team?: TeamRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecadeMeta = {
|
||||||
|
/** 2010, 2020, … */
|
||||||
|
decade: number
|
||||||
|
title: string
|
||||||
|
tagline: string
|
||||||
|
blurb?: string
|
||||||
|
/** Renders the ghosted treatment and the "before NGU" marker. */
|
||||||
|
preProgram?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedMonth = {
|
||||||
|
month: number
|
||||||
|
label: string
|
||||||
|
items: TimelineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedYear = {
|
||||||
|
year: number
|
||||||
|
featured: boolean
|
||||||
|
count: number
|
||||||
|
featuredItems: TimelineItem[]
|
||||||
|
/** Year-precision items — known to be this year, month unknown. */
|
||||||
|
undated: TimelineItem[]
|
||||||
|
months: GroupedMonth[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedDecade = DecadeMeta & {
|
||||||
|
years: GroupedYear[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDirection = 'desc' | 'asc'
|
||||||
|
|
||||||
|
export const MONTH_LABELS = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function decadeOf(year: number): number {
|
||||||
|
return Math.floor(year / 10) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decadeLabel(decade: number): string {
|
||||||
|
return `${decade}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DateParts = { year: number; month: number | null; day: number | null }
|
||||||
|
|
||||||
|
function parseDate(item: TimelineItem): DateParts {
|
||||||
|
const [y, m, d] = item.date.split('-')
|
||||||
|
const year = Number(y)
|
||||||
|
if (!Number.isFinite(year)) {
|
||||||
|
throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`)
|
||||||
|
}
|
||||||
|
if (item.precision === 'year') return { year, month: null, day: null }
|
||||||
|
const month = m ? Number(m) : null
|
||||||
|
if (item.precision === 'month') return { year, month, day: null }
|
||||||
|
return { year, month, day: d ? Number(d) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start of the item's date window, as a sortable YYYY-MM-DD.
|
||||||
|
*
|
||||||
|
* A year-precision item resolves to 1 January, a month-precision one to
|
||||||
|
* the 1st. That makes "is this still upcoming?" answerable for imprecise
|
||||||
|
* dates in the one way that can't surprise anyone: an entry stops being
|
||||||
|
* upcoming as soon as any part of its window has passed. A row dated
|
||||||
|
* only "2027" is upcoming through the end of 2026 and no longer is on
|
||||||
|
* 1 January 2027, even though its real date may be months away.
|
||||||
|
*/
|
||||||
|
export function windowStart(item: TimelineItem): string {
|
||||||
|
const { year, month, day } = parseDate(item)
|
||||||
|
return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(now: Date = new Date()): string {
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split upcoming from recorded, off the wall clock rather than a flag.
|
||||||
|
* Nothing needs flipping when a date passes.
|
||||||
|
*/
|
||||||
|
export function partitionByDate(
|
||||||
|
items: TimelineItem[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): { upcoming: TimelineItem[]; past: TimelineItem[] } {
|
||||||
|
const today = todayISO(now)
|
||||||
|
const upcoming: TimelineItem[] = []
|
||||||
|
const past: TimelineItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (windowStart(item) > today) upcoming.push(item)
|
||||||
|
else past.push(item)
|
||||||
|
}
|
||||||
|
return { upcoming, past }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── grouping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function byDay(dir: SortDirection) {
|
||||||
|
return (a: TimelineItem, b: TimelineItem) => {
|
||||||
|
const da = parseDate(a).day
|
||||||
|
const db = parseDate(b).day
|
||||||
|
if (da == null && db == null) return a.title.localeCompare(b.title)
|
||||||
|
if (da == null) return 1
|
||||||
|
if (db == null) return -1
|
||||||
|
return dir === 'desc' ? db - da : da - db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byFeaturedThenDay(dir: SortDirection) {
|
||||||
|
const day = byDay(dir)
|
||||||
|
return (a: TimelineItem, b: TimelineItem) => {
|
||||||
|
if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1
|
||||||
|
return day(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupOptions = {
|
||||||
|
direction?: SortDirection
|
||||||
|
/**
|
||||||
|
* Decades ending before this year get the pre-program treatment even
|
||||||
|
* if the decade row doesn't say so. Lets the gap survive missing
|
||||||
|
* metadata.
|
||||||
|
*/
|
||||||
|
programStartYear?: number
|
||||||
|
/**
|
||||||
|
* Repeat featured items inside their month node as well as in the
|
||||||
|
* featured block. Off by default — in a sparse year it just prints the
|
||||||
|
* same line twice. A month left with nothing but featured items drops
|
||||||
|
* out entirely.
|
||||||
|
*/
|
||||||
|
featuredInMonths?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket a flat item list into years. Shared by the main rail and the
|
||||||
|
* upcoming block above it. */
|
||||||
|
export function groupYears(
|
||||||
|
items: TimelineItem[],
|
||||||
|
options: GroupOptions = {},
|
||||||
|
): GroupedYear[] {
|
||||||
|
const direction = options.direction ?? 'desc'
|
||||||
|
const featuredInMonths = options.featuredInMonths ?? false
|
||||||
|
const sign = direction === 'desc' ? -1 : 1
|
||||||
|
|
||||||
|
const yearBuckets = new Map<number, TimelineItem[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const { year } = parseDate(item)
|
||||||
|
const bucket = yearBuckets.get(year)
|
||||||
|
if (bucket) bucket.push(item)
|
||||||
|
else yearBuckets.set(year, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const years: GroupedYear[] = []
|
||||||
|
|
||||||
|
for (const [year, yearItems] of yearBuckets) {
|
||||||
|
const featuredItems: TimelineItem[] = []
|
||||||
|
const undated: TimelineItem[] = []
|
||||||
|
const monthMap = new Map<number, TimelineItem[]>()
|
||||||
|
|
||||||
|
for (const item of yearItems) {
|
||||||
|
if (item.featured) {
|
||||||
|
featuredItems.push(item)
|
||||||
|
if (!featuredInMonths) continue
|
||||||
|
}
|
||||||
|
const { month } = parseDate(item)
|
||||||
|
if (month == null) {
|
||||||
|
undated.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const bucket = monthMap.get(month)
|
||||||
|
if (bucket) bucket.push(item)
|
||||||
|
else monthMap.set(month, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const months: GroupedMonth[] = [...monthMap.entries()]
|
||||||
|
.sort((a, b) => sign * (a[0] - b[0]))
|
||||||
|
.map(([month, monthItems]) => ({
|
||||||
|
month,
|
||||||
|
label: MONTH_LABELS[month - 1] ?? `Month ${month}`,
|
||||||
|
items: monthItems.sort(byFeaturedThenDay(direction)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
featuredItems.sort(byDay(direction))
|
||||||
|
undated.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
|
||||||
|
years.push({
|
||||||
|
year,
|
||||||
|
featured: featuredItems.length > 0,
|
||||||
|
count: yearItems.length,
|
||||||
|
featuredItems,
|
||||||
|
undated,
|
||||||
|
months,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return years.sort((a, b) => sign * (a.year - b.year))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTimeline(
|
||||||
|
items: TimelineItem[],
|
||||||
|
decades: DecadeMeta[],
|
||||||
|
options: GroupOptions = {},
|
||||||
|
): GroupedDecade[] {
|
||||||
|
const direction = options.direction ?? 'desc'
|
||||||
|
const sign = direction === 'desc' ? -1 : 1
|
||||||
|
const metaByDecade = new Map(decades.map((d) => [d.decade, d]))
|
||||||
|
|
||||||
|
const decadeBuckets = new Map<number, GroupedYear[]>()
|
||||||
|
for (const year of groupYears(items, options)) {
|
||||||
|
const dec = decadeOf(year.year)
|
||||||
|
const bucket = decadeBuckets.get(dec)
|
||||||
|
if (bucket) bucket.push(year)
|
||||||
|
else decadeBuckets.set(dec, [year])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include decades that have metadata but no items yet, so an authored
|
||||||
|
// "before NGU" decade still renders its marker.
|
||||||
|
for (const meta of decades) {
|
||||||
|
if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...decadeBuckets.entries()]
|
||||||
|
.sort((a, b) => sign * (a[0] - b[0]))
|
||||||
|
.map(([decade, years]) => {
|
||||||
|
const meta = metaByDecade.get(decade)
|
||||||
|
const inferredPreProgram =
|
||||||
|
options.programStartYear != null && decade + 9 < options.programStartYear
|
||||||
|
return {
|
||||||
|
decade,
|
||||||
|
title: meta?.title ?? decadeLabel(decade),
|
||||||
|
tagline: meta?.tagline ?? '',
|
||||||
|
blurb: meta?.blurb,
|
||||||
|
preProgram: meta?.preProgram ?? inferredPreProgram,
|
||||||
|
years: years.sort((a, b) => sign * (a.year - b.year)),
|
||||||
|
count: years.reduce((sum, y) => sum + y.count, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert empty year nodes between the first and last year that actually
|
||||||
|
* has data, so sparse decades read as gaps in the record rather than as
|
||||||
|
* a shorter decade. Does not pad beyond the data.
|
||||||
|
*/
|
||||||
|
export function withGapYears(
|
||||||
|
years: GroupedYear[],
|
||||||
|
direction: SortDirection = 'desc',
|
||||||
|
): GroupedYear[] {
|
||||||
|
if (years.length < 2) return years
|
||||||
|
|
||||||
|
const present = new Map(years.map((y) => [y.year, y]))
|
||||||
|
const all = years.map((y) => y.year)
|
||||||
|
const min = Math.min(...all)
|
||||||
|
const max = Math.max(...all)
|
||||||
|
const filled: GroupedYear[] = []
|
||||||
|
|
||||||
|
for (let year = min; year <= max; year += 1) {
|
||||||
|
filled.push(
|
||||||
|
present.get(year) ?? {
|
||||||
|
year,
|
||||||
|
featured: false,
|
||||||
|
count: 0,
|
||||||
|
featuredItems: [],
|
||||||
|
undated: [],
|
||||||
|
months: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'desc' ? filled.reverse() : filled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Years that should start expanded: the most recent year with featured items. */
|
||||||
|
export function defaultOpenYears(decades: GroupedDecade[]): number[] {
|
||||||
|
for (const decade of decades) {
|
||||||
|
if (decade.preProgram) continue
|
||||||
|
const hit = decade.years.find((y) => y.featured)
|
||||||
|
if (hit) return [hit.year]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
75
src/lib/timelineRefs.ts
Normal file
75
src/lib/timelineRefs.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TIMELINE REFS
|
||||||
|
|
||||||
|
Turns a timeline item into a destination and an image. Both were
|
||||||
|
answered locally here before, which is how the timeline came to
|
||||||
|
link events at /event/:id while the router only knew about
|
||||||
|
/retreats/:id — two files holding the same opinion, one of them
|
||||||
|
wrong, neither aware of the other.
|
||||||
|
|
||||||
|
Now this file knows about timeline items and nothing else. Where
|
||||||
|
a record lives is hrefs.ts; where an image lives is media.ts.
|
||||||
|
|
||||||
|
── The shape this reads, from history.js ──
|
||||||
|
|
||||||
|
item.href explicit link_url override, may be off-site
|
||||||
|
item.ref { kind, id, orgKind? } — orgKind only on
|
||||||
|
organizations, because only they have it
|
||||||
|
item.logo { file, kind } — v_timeline COALESCEs the
|
||||||
|
filename across five tables, so the kind is
|
||||||
|
what says which directory it came from
|
||||||
|
item.team { id, name, orgId? } on a team ref
|
||||||
|
item.people[] { id, name, photo?, title? }
|
||||||
|
|
||||||
|
Every one of those is optional. An entry is a standalone
|
||||||
|
milestone until proven otherwise, and the two accessors below
|
||||||
|
return null rather than assuming a shape that isn't there —
|
||||||
|
which is the other half of the /organizations/undefined bug:
|
||||||
|
reaching into a ref that wasn't sent yields undefined, and
|
||||||
|
undefined interpolates into a path perfectly happily.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { refHref } from './hrefs.ts'
|
||||||
|
import { logoForKind, personPhoto } from './media.ts'
|
||||||
|
import type { TimelineItem } from './timeline.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this entry points, or null if nowhere.
|
||||||
|
*
|
||||||
|
* An explicit link_url wins: it's the editor deliberately
|
||||||
|
* overriding the record's own page, usually to send someone to an
|
||||||
|
* external write-up. TimelineEntry checks for a scheme and renders
|
||||||
|
* an <a> instead of a <Link>, so this returns it unchanged.
|
||||||
|
*
|
||||||
|
* Otherwise the reference decides, and refHref returns null for a
|
||||||
|
* kind with no page yet ('person', until /people/:id exists) as
|
||||||
|
* well as for a ref that didn't resolve. Null means the entry
|
||||||
|
* renders as a plain <div> — right for a milestone, and right for
|
||||||
|
* a reference that's missing an id, which used to render as a link
|
||||||
|
* to a 404.
|
||||||
|
*/
|
||||||
|
export function hrefFor(item: TimelineItem): string | null {
|
||||||
|
if (item.href) return item.href
|
||||||
|
|
||||||
|
const ref = item.ref
|
||||||
|
if (!ref) return null
|
||||||
|
|
||||||
|
return refHref(ref.kind, ref.id, ref.orgKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry's image, resolved against the directory for whatever
|
||||||
|
* kind of record the filename came from.
|
||||||
|
*/
|
||||||
|
export function logoSrc(item: TimelineItem): string | null {
|
||||||
|
if (!item.logo) return null
|
||||||
|
return logoForKind(item.logo.kind, item.logo.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A roster member's photo. Same rule as everywhere else — the API
|
||||||
|
* sends a bare filename.
|
||||||
|
*/
|
||||||
|
export function photoSrc(photo?: string | null): string | null {
|
||||||
|
return personPhoto(photo)
|
||||||
|
}
|
||||||
243
src/lib/useContent.ts
Normal file
243
src/lib/useContent.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* The four detail endpoints, typed.
|
||||||
|
*
|
||||||
|
* Adding a fifth is a type and a one-line hook — useRecord owns
|
||||||
|
* the fetching, the caching and the 404.
|
||||||
|
*
|
||||||
|
* An id that is missing — or the literal string "undefined", which
|
||||||
|
* is what a template literal makes of a missing route param — never
|
||||||
|
* reaches the network. detailPath returns null and the hook reports
|
||||||
|
* notFound, which is what the visitor should see anyway, and
|
||||||
|
* hrefs.ts has already logged the component that produced it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detailPath } from './hrefs.ts'
|
||||||
|
import { useRecord, type Resource } from './useRecord.ts'
|
||||||
|
import type { EventType } from './eventTypes.ts'
|
||||||
|
|
||||||
|
/* ── Shared shapes ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** A content_blocks row with its `items` child, as shape.js sends it. */
|
||||||
|
export type ContentBlock = {
|
||||||
|
id?: number | string
|
||||||
|
slot?: 'card' | 'body'
|
||||||
|
type:
|
||||||
|
| 'heading'
|
||||||
|
| 'subheading'
|
||||||
|
| 'paragraph'
|
||||||
|
| 'list'
|
||||||
|
| 'links'
|
||||||
|
| 'quote'
|
||||||
|
| 'image'
|
||||||
|
| 'divider'
|
||||||
|
text?: string | null
|
||||||
|
media?: string | null
|
||||||
|
href?: string | null
|
||||||
|
items?: Array<{ text: string; detail?: string | null; url?: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
kind?: string
|
||||||
|
platform?: string | null
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
is_primary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgRef = { id: string; name: string; kind?: string | null }
|
||||||
|
|
||||||
|
/* One host of an event. `kind` says which table the id is in;
|
||||||
|
`org_kind` is the region/chapter/partner split that decides an
|
||||||
|
organization's route, and is null for a person. The triple is
|
||||||
|
exactly what refHref() in hrefs.ts takes. */
|
||||||
|
export type EventHost = {
|
||||||
|
kind: 'organization' | 'person'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
org_kind?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Events ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type EventPerson = {
|
||||||
|
person_id: string
|
||||||
|
display_name: string
|
||||||
|
pronouns?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
photo?: string | null
|
||||||
|
role?: string | null
|
||||||
|
title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventAward = {
|
||||||
|
award: { id: string; name: string; logo?: string | null }
|
||||||
|
person: { id: string; name: string; photo?: string | null }
|
||||||
|
awarded_on?: string | null
|
||||||
|
citation?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventRecord = {
|
||||||
|
id: string
|
||||||
|
/** Which band of the Retreats page this belongs to. */
|
||||||
|
section_id: string
|
||||||
|
/** What kind of gathering it is. Orthogonal to section_id. */
|
||||||
|
event_type: EventType
|
||||||
|
title: string
|
||||||
|
theme?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
starts_on?: string | null
|
||||||
|
ends_on?: string | null
|
||||||
|
date_label?: string | null
|
||||||
|
status: 'upcoming' | 'past' | 'cancelled'
|
||||||
|
location_label?: string | null
|
||||||
|
locality?: string | null
|
||||||
|
state_code?: string | null
|
||||||
|
country?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
org_logo?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
color?: string | null
|
||||||
|
gradient?: string | null
|
||||||
|
/* In billing order. The first is the one `color` and `org_logo`
|
||||||
|
fell back to when the event set neither. */
|
||||||
|
hosts: EventHost[]
|
||||||
|
description: string[]
|
||||||
|
links: Link[]
|
||||||
|
instagram?: Link | null
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
people: EventPerson[]
|
||||||
|
awards: EventAward[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||||
|
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||||
|
|
||||||
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Leader = {
|
||||||
|
person_id: string
|
||||||
|
display_name: string
|
||||||
|
pronouns?: string | null
|
||||||
|
title?: string | null
|
||||||
|
role?: string | null
|
||||||
|
is_owner: boolean
|
||||||
|
photo?: string | null
|
||||||
|
public_email?: string | null
|
||||||
|
team_id?: string | null
|
||||||
|
team_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgTeam = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgAward = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
recipient_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgEvent = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
event_type: EventType
|
||||||
|
date_label?: string | null
|
||||||
|
status: string
|
||||||
|
location_label?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
color?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganizationRecord = {
|
||||||
|
id: string
|
||||||
|
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
name: string
|
||||||
|
short_name?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
venue?: string | null
|
||||||
|
address?: string | null
|
||||||
|
locality?: string | null
|
||||||
|
state_code?: string | null
|
||||||
|
country?: string | null
|
||||||
|
location_label?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
description: string[]
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
links: Link[]
|
||||||
|
socials: Link[]
|
||||||
|
website?: Link | null
|
||||||
|
email?: Link | null
|
||||||
|
instagram?: Link | null
|
||||||
|
/** Shape depends on `kind`; empty object for national and partner. */
|
||||||
|
details: {
|
||||||
|
scope?: string | null
|
||||||
|
map_note?: string | null
|
||||||
|
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||||
|
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||||
|
region_id?: string | null
|
||||||
|
region_name?: string | null
|
||||||
|
region_color?: string | null
|
||||||
|
meets?: string | null
|
||||||
|
started?: string | null
|
||||||
|
}
|
||||||
|
leadership: Leader[]
|
||||||
|
teams: OrgTeam[]
|
||||||
|
awards: OrgAward[]
|
||||||
|
events: OrgEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||||
|
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||||
|
|
||||||
|
/* ── Teams ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** No members here on purpose — PeopleTiles fetches
|
||||||
|
* /teams/:id/people itself. See the note in content.js. */
|
||||||
|
export type TeamRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
org: OrgRef
|
||||||
|
description: string[]
|
||||||
|
links: Link[]
|
||||||
|
socials: Link[]
|
||||||
|
instagram?: Link | null
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTeam = (id?: string): Resource<TeamRecord> =>
|
||||||
|
useRecord<TeamRecord>(detailPath('/teams', id), 'team')
|
||||||
|
|
||||||
|
/* ── Awards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Recipient = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
awarded_on?: string | null
|
||||||
|
citation?: string | null
|
||||||
|
event: { id: string; title: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AwardRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
org: OrgRef | null
|
||||||
|
recipients: Recipient[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAward = (id?: string): Resource<AwardRecord> =>
|
||||||
|
useRecord<AwardRecord>(detailPath('/awards', id), 'award')
|
||||||
87
src/lib/useHistory.ts
Normal file
87
src/lib/useHistory.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Loads the history timeline from `GET /api/history`.
|
||||||
|
*
|
||||||
|
* Goes through the shared client, so the request is deduped and cached
|
||||||
|
* for 60s like every other read. Two consequences worth knowing:
|
||||||
|
*
|
||||||
|
* · `reload` has to invalidate before it refetches. Without that, the
|
||||||
|
* retry button inside the TTL would hand back the same settled
|
||||||
|
* promise and look like it did nothing. A *failed* request is
|
||||||
|
* already evicted by the client, so this matters for the refresh
|
||||||
|
* case rather than the error case.
|
||||||
|
*
|
||||||
|
* · there's no AbortController. `get` shares one promise between
|
||||||
|
* callers, so aborting on unmount would cancel someone else's
|
||||||
|
* request. The `live` flag drops the result instead.
|
||||||
|
*
|
||||||
|
* No `fallback` on purpose. Handing this the mock data would render a
|
||||||
|
* plausible-looking history with no indication the server is down, and
|
||||||
|
* the wrong history is worse than a visible error — the same reason
|
||||||
|
* `fallback: EMPTY` came out elsewhere.
|
||||||
|
*
|
||||||
|
* `undated` is the number of published entries the API left out because
|
||||||
|
* nothing gave them a date. Not rendered publicly — a visitor can't act
|
||||||
|
* on it — but returned so it's reachable if you want a warning in the
|
||||||
|
* admin later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { get, invalidate, ApiError } from './api.js'
|
||||||
|
import type { TimelineItem } from './timeline'
|
||||||
|
|
||||||
|
const PATH = '/history'
|
||||||
|
|
||||||
|
type HistoryResponse = {
|
||||||
|
items?: TimelineItem[]
|
||||||
|
undated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
items: TimelineItem[]
|
||||||
|
undated: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistory(): State {
|
||||||
|
const [items, setItems] = useState<TimelineItem[]>([])
|
||||||
|
const [undated, setUndated] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
invalidate(PATH)
|
||||||
|
setAttempt((n) => n + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data: HistoryResponse = await get(PATH)
|
||||||
|
if (!live) return
|
||||||
|
setItems(Array.isArray(data?.items) ? data.items : [])
|
||||||
|
setUndated(Number(data?.undated) || 0)
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [attempt])
|
||||||
|
|
||||||
|
return { items, undated, loading, error, reload }
|
||||||
|
}
|
||||||
127
src/lib/useRecord.ts
Normal file
127
src/lib/useRecord.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* useRecord — one record from one endpoint, on the useHistory
|
||||||
|
* pattern.
|
||||||
|
*
|
||||||
|
* Named for what it returns, and deliberately not useResource:
|
||||||
|
* src/lib/useResource.js is a different hook — it hands back the
|
||||||
|
* whole response body and takes an options object — and a .ts file
|
||||||
|
* of the same name would sit one extension away from it. An import
|
||||||
|
* whose target goes missing then resolves to the other file without
|
||||||
|
* a word, and every detail page renders the wrapper object instead
|
||||||
|
* of the record. That is exactly how this file came to exist.
|
||||||
|
*
|
||||||
|
* Every detail route in content.js answers the same shape: 200 with
|
||||||
|
* a single top-level key, or 404 with `{ error }`. So the hook takes
|
||||||
|
* the path and the key, and the four callers in useContent.ts are
|
||||||
|
* one line each rather than four copies of this file.
|
||||||
|
*
|
||||||
|
* `key` is a string rather than a selector function on purpose. A
|
||||||
|
* selector passed inline would be a new identity every render, and
|
||||||
|
* putting it in the effect's deps would refetch forever; leaving it
|
||||||
|
* out would silently use a stale closure. A string has neither
|
||||||
|
* problem.
|
||||||
|
*
|
||||||
|
* Carried over from useHistory, and worth restating:
|
||||||
|
*
|
||||||
|
* · `reload` invalidates before it refetches. Without that, the
|
||||||
|
* retry button inside the 60s TTL hands back the same settled
|
||||||
|
* promise and looks like it did nothing. A *failed* request is
|
||||||
|
* already evicted by api.js, so this matters for the refresh
|
||||||
|
* case rather than the error case.
|
||||||
|
*
|
||||||
|
* · no AbortController. `get` shares one promise between callers,
|
||||||
|
* so aborting on unmount would cancel someone else's request.
|
||||||
|
* The `live` flag drops the result instead.
|
||||||
|
*
|
||||||
|
* · no `fallback`. Rendering plausible-looking content with no
|
||||||
|
* sign the server is down is worse than a visible error.
|
||||||
|
*
|
||||||
|
* `notFound` is separated from `error` because they are different
|
||||||
|
* pages: a 404 is a slug that doesn't exist and retrying won't help,
|
||||||
|
* anything else is worth a Try again button.
|
||||||
|
*
|
||||||
|
* A null `path` means there is nothing to ask for, and the hook
|
||||||
|
* reports notFound rather than loading forever. Callers build the
|
||||||
|
* path with detailPath() from hrefs.ts, which returns null for an
|
||||||
|
* id that could never be real — "undefined" chief among them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { get, invalidate, ApiError } from './api.js'
|
||||||
|
|
||||||
|
export type Resource<T> = {
|
||||||
|
data: T | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
notFound: boolean
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
if (path) invalidate(path)
|
||||||
|
setAttempt((n) => n + 1)
|
||||||
|
}, [path])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Nothing to ask for. Distinguished from "not asked yet" by the
|
||||||
|
// caller: useContent passes null only when the id is unusable,
|
||||||
|
// and an unusable id is a 404 as far as the visitor is
|
||||||
|
// concerned.
|
||||||
|
if (!path) {
|
||||||
|
setData(null)
|
||||||
|
setError(null)
|
||||||
|
setNotFound(true)
|
||||||
|
setLoading(false)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setNotFound(false)
|
||||||
|
try {
|
||||||
|
const body = await get(path as string)
|
||||||
|
if (!live) return
|
||||||
|
// A 200 with the key absent is a server-side shaping bug,
|
||||||
|
// not an empty record. Say so rather than rendering a page
|
||||||
|
// full of blanks.
|
||||||
|
const record = body?.[key]
|
||||||
|
if (record === undefined) {
|
||||||
|
setError(`The server sent no "${key}".`)
|
||||||
|
setData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData(record as T)
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
setNotFound(true)
|
||||||
|
setData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||||
|
)
|
||||||
|
setData(null)
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [path, key, attempt])
|
||||||
|
|
||||||
|
return { data, loading, error, notFound, reload }
|
||||||
|
}
|
||||||
10
src/lib/version.ts
Normal file
10
src/lib/version.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
SITE VERSION — src/lib/version.ts
|
||||||
|
|
||||||
|
One string, bumped by hand when the working directory name
|
||||||
|
changes. The admin footer is the only thing reading it today;
|
||||||
|
keep it here rather than in a component so the panel, an about
|
||||||
|
box or a build banner can read the same value later.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export const SITE_VERSION = "NGU-Web.v1.5-history";
|
||||||
|
|
@ -7,6 +7,7 @@ export const PAGE_LINKS = [
|
||||||
{ label: "Community", path: "/community" },
|
{ label: "Community", path: "/community" },
|
||||||
{ label: "Leadership", path: "/leadership" },
|
{ label: "Leadership", path: "/leadership" },
|
||||||
{ label: "Resources", path: "/resources" },
|
{ label: "Resources", path: "/resources" },
|
||||||
|
{ label: "History", path: "/history" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sections belong to a page, keyed by that page's path.
|
// Sections belong to a page, keyed by that page's path.
|
||||||
|
|
@ -38,6 +39,9 @@ export const PAGE_SECTIONS = {
|
||||||
{ label: "Branding & Marketing", hash: "#branding" },
|
{ label: "Branding & Marketing", hash: "#branding" },
|
||||||
{ label: "Partner Resources", hash: "#partner" },
|
{ label: "Partner Resources", hash: "#partner" },
|
||||||
],
|
],
|
||||||
|
"/history": [
|
||||||
|
{ label: "Timeline", hash: "#timeline" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Right-side actions. `variant` picks the styling, not the destination.
|
// Right-side actions. `variant` picks the styling, not the destination.
|
||||||
|
|
|
||||||
178
src/pages/AwardDetail.tsx
Normal file
178
src/pages/AwardDetail.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
AWARD DETAIL — /awards/:id
|
||||||
|
|
||||||
|
The thinnest of the four, by design. `awards` has no links and no
|
||||||
|
content blocks — 'award' isn't in the owner_kind CHECK on either
|
||||||
|
polymorphic table, and widening it means rebuilding two STRICT
|
||||||
|
tables. `description` is the prose.
|
||||||
|
|
||||||
|
So the recipients are the page. They're rendered as a list rather
|
||||||
|
than as PeopleTiles because each one carries a citation, and a
|
||||||
|
citation is the point of an award — it doesn't belong folded
|
||||||
|
behind a chevron.
|
||||||
|
|
||||||
|
Note also that `awards` has no is_published column: every row
|
||||||
|
here is live the moment it's created. Worth an ADD COLUMN before
|
||||||
|
this ships.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import { useAward, type Recipient } from '../lib/useContent.ts'
|
||||||
|
import { eventHref, orgHref, personHref } from '../lib/hrefs.ts'
|
||||||
|
import { awardLogo, initials, personPhoto } from '../lib/media.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function AwardDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: award, loading, error, notFound, reload } = useAward(id)
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="award"
|
||||||
|
backTo="/community"
|
||||||
|
backLabel="Community"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = TEAL
|
||||||
|
const logo = awardLogo(award.logo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={award.name}
|
||||||
|
intro={award.description ?? undefined}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: 'recipients',
|
||||||
|
title: 'Recipients',
|
||||||
|
blurb:
|
||||||
|
award.recipients.length === 0
|
||||||
|
? 'Nobody has received this yet.'
|
||||||
|
: undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
{logo && (
|
||||||
|
<img src={logo} alt="" loading="lazy" className="h-12 w-12 object-contain" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{award.org && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Given by{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(award.org.id, award.org.kind)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{award.org.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
{award.recipients.length}{' '}
|
||||||
|
{award.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-8">
|
||||||
|
{award.recipients.map((recipient, index) => (
|
||||||
|
<li key={`${recipient.id}:${recipient.awarded_on ?? index}`}>
|
||||||
|
<RecipientRow recipient={recipient} accent={accent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipientRow({ recipient, accent }: { recipient: Recipient; accent: string }) {
|
||||||
|
const photo = personPhoto(recipient.photo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{photo ? (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{initials(recipient.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
<Link
|
||||||
|
to={personHref(recipient.id)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{recipient.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{[
|
||||||
|
year(recipient.awarded_on),
|
||||||
|
recipient.tagline,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
{recipient.event && (
|
||||||
|
<>
|
||||||
|
{(recipient.awarded_on || recipient.tagline) && ' · '}
|
||||||
|
<Link
|
||||||
|
to={eventHref(recipient.event.id)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{recipient.event.title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{recipient.citation && (
|
||||||
|
<p className="mt-2 max-w-2xl leading-relaxed" style={{ color: BODY }}>
|
||||||
|
{recipient.citation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* awarded_on may be a partial date, so this takes the leading year
|
||||||
|
rather than parsing. A full date would be false precision on a
|
||||||
|
row backfilled from a programme booklet. */
|
||||||
|
function year(date?: string | null): string | null {
|
||||||
|
if (!date) return null
|
||||||
|
const match = /^(\d{4})/.exec(date)
|
||||||
|
return match ? match[1] : date
|
||||||
|
}
|
||||||
352
src/pages/EventDetail.tsx
Normal file
352
src/pages/EventDetail.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT DETAIL — /retreats/:id
|
||||||
|
|
||||||
|
Reached from the cards on Retreats.tsx. The API has already
|
||||||
|
resolved the fallbacks — `color` is the event's own or its first
|
||||||
|
host's, `status` is derived from the dates when nobody set it —
|
||||||
|
so nothing here reimplements those rules.
|
||||||
|
|
||||||
|
Hosts arrive as a list in billing order, each one either an
|
||||||
|
organization or a person. refHref takes the kind and works out
|
||||||
|
the route, which is why this file doesn't branch on it.
|
||||||
|
|
||||||
|
People are grouped by their billing role rather than listed flat.
|
||||||
|
A retreat with four speakers and eleven volunteers reads as two
|
||||||
|
different things, and v_event_people already sorts within a role
|
||||||
|
by sort_order.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||||
|
import {
|
||||||
|
useEvent,
|
||||||
|
type EventHost,
|
||||||
|
type EventPerson,
|
||||||
|
type EventRecord,
|
||||||
|
} from '../lib/useContent.ts'
|
||||||
|
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
||||||
|
import { personPhoto } from '../lib/media.ts'
|
||||||
|
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
/* Billing order. A role missing from here still renders, at the
|
||||||
|
end, under its own name — better than a speaker vanishing
|
||||||
|
because somebody added a role to the CHECK and not to this list. */
|
||||||
|
const ROLE_ORDER = [
|
||||||
|
'speaker',
|
||||||
|
'leader',
|
||||||
|
'facilitator',
|
||||||
|
'host',
|
||||||
|
'musician',
|
||||||
|
'volunteer',
|
||||||
|
'attendee',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
speaker: 'Speakers',
|
||||||
|
leader: 'Leaders',
|
||||||
|
facilitator: 'Facilitators',
|
||||||
|
host: 'Hosts',
|
||||||
|
musician: 'Music',
|
||||||
|
volunteer: 'Volunteers',
|
||||||
|
attendee: 'Also there',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
past: 'Past',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: event, loading, error, notFound, reload } = useEvent(id)
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="retreat"
|
||||||
|
backTo="/retreats"
|
||||||
|
backLabel="All retreats"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = event.color || TEAL
|
||||||
|
const groups = peopleGroups(event.people, accent)
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: event.theme || 'About',
|
||||||
|
blurb: event.theme ? event.tagline ?? undefined : undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts event={event} accent={accent} />
|
||||||
|
|
||||||
|
{event.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={event.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.links.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{event.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (groups.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'people',
|
||||||
|
title: 'Who’s there',
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<PeopleTiles size="lg" groups={groups} accent={accent} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.awards.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'awards',
|
||||||
|
title: 'Presented here',
|
||||||
|
blurb: 'Awards given at this gathering.',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||||
|
{event.awards.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.award.id}:${entry.person.id}:${index}`}
|
||||||
|
className="flex gap-4 border-l-2 pl-5"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
<Link to={awardHref(entry.award.id)} className="hover:underline">
|
||||||
|
{entry.award.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p style={{ color: BODY }}>
|
||||||
|
<Link to={personHref(entry.person.id)} className="hover:underline">
|
||||||
|
{entry.person.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{entry.citation && (
|
||||||
|
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
|
||||||
|
{entry.citation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={event.title}
|
||||||
|
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The strip of facts under the heading ────────────────────── */
|
||||||
|
|
||||||
|
function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
||||||
|
const where =
|
||||||
|
event.location_label ||
|
||||||
|
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
||||||
|
(event.is_online ? 'Online' : null)
|
||||||
|
|
||||||
|
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
<span
|
||||||
|
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||||
|
style={{
|
||||||
|
background: event.status === 'cancelled' ? '#b3261e' : accent,
|
||||||
|
color: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[event.status] ?? event.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Outlined rather than filled: the status pill is the one
|
||||||
|
thing in this strip that should read as loud, and two
|
||||||
|
solid blocks side by side would compete. Shown
|
||||||
|
unconditionally — a card suppresses its own type badge in
|
||||||
|
a band of one kind, but here there is no band to make it
|
||||||
|
redundant. */}
|
||||||
|
{event.event_type && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||||
|
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||||
|
>
|
||||||
|
{eventTypeLabel(event.event_type)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{when && <span style={{ color: BODY }}>{when}</span>}
|
||||||
|
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||||
|
{event.is_online && where !== 'Online' && (
|
||||||
|
<span style={{ color: BODY }}>Online too</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(event.hosts?.length ?? 0) > 0 && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Hosted by <HostList hosts={event.hosts} accent={accent} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
|
||||||
|
All retreats
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One host reads as "Hosted by Northwest"; several read as a
|
||||||
|
sentence, so they're joined with commas and an "and" rather than
|
||||||
|
stacked. A host whose id didn't resolve to a route renders as
|
||||||
|
plain text — refHref returns null for that — because a dead link
|
||||||
|
is worse than a name. */
|
||||||
|
function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hosts.map((host, index) => {
|
||||||
|
const to = refHref(host.kind, host.id, host.org_kind)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={`${host.kind}:${host.id}`}>
|
||||||
|
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
|
||||||
|
{index > 0 && index === hosts.length - 1 && 'and '}
|
||||||
|
{to ? (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{host.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{host.name}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* date_label is what a card shows and is free text — "March/April
|
||||||
|
2026" is legitimate. This is only the fallback for an event that
|
||||||
|
has dates and no label. */
|
||||||
|
function dateRange(start?: string | null, end?: string | null): string | null {
|
||||||
|
if (!start) return null
|
||||||
|
const from = new Date(`${start}T00:00:00`)
|
||||||
|
if (Number.isNaN(from.getTime())) return start
|
||||||
|
|
||||||
|
const full: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!end || end === start) return from.toLocaleDateString(undefined, full)
|
||||||
|
|
||||||
|
const to = new Date(`${end}T00:00:00`)
|
||||||
|
if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full)
|
||||||
|
|
||||||
|
const sameYear = from.getFullYear() === to.getFullYear()
|
||||||
|
const sameMonth = sameYear && from.getMonth() === to.getMonth()
|
||||||
|
|
||||||
|
const left = from.toLocaleDateString(
|
||||||
|
undefined,
|
||||||
|
sameMonth
|
||||||
|
? { month: 'long', day: 'numeric' }
|
||||||
|
: sameYear
|
||||||
|
? { month: 'long', day: 'numeric' }
|
||||||
|
: full,
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${left} – ${to.toLocaleDateString(undefined, full)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── v_event_people → PeopleTiles groups ─────────────────────── */
|
||||||
|
|
||||||
|
function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] {
|
||||||
|
if (!people.length) return []
|
||||||
|
|
||||||
|
const byRole = new Map<string, EventPerson[]>()
|
||||||
|
for (const person of people) {
|
||||||
|
const role = person.role || 'attendee'
|
||||||
|
const list = byRole.get(role)
|
||||||
|
if (list) list.push(person)
|
||||||
|
else byRole.set(role, [person])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known roles in billing order, then anything the CHECK has
|
||||||
|
// gained since this file was written.
|
||||||
|
const roles = [
|
||||||
|
...ROLE_ORDER.filter((role) => byRole.has(role)),
|
||||||
|
...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)),
|
||||||
|
]
|
||||||
|
|
||||||
|
return roles.map((role) => ({
|
||||||
|
id: `role-${role}`,
|
||||||
|
label: ROLE_LABEL[role] ?? capitalize(role),
|
||||||
|
accent,
|
||||||
|
people: (byRole.get(role) ?? []).map((person) => ({
|
||||||
|
id: person.person_id,
|
||||||
|
name: person.display_name,
|
||||||
|
title: person.title,
|
||||||
|
tagline: person.tagline,
|
||||||
|
pronouns: person.pronouns,
|
||||||
|
// PeopleTiles resolves a bare filename itself; this is here
|
||||||
|
// so a hand-written entry elsewhere can't diverge.
|
||||||
|
photo: personPhoto(person.photo),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
84
src/pages/History.tsx
Normal file
84
src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import PageShell from "../components/PageShell";
|
||||||
|
import HistoryTimeline from "./sections/history/HistoryTimeline";
|
||||||
|
import { HISTORY_DECADES } from "../data/historyDecades";
|
||||||
|
import { useHistory } from "../lib/useHistory";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year the program starts. Decades ending before this render with the
|
||||||
|
* pre-program treatment, so the gap moves if the founding date is ever
|
||||||
|
* corrected — no hardcoded 2000 anywhere in the components.
|
||||||
|
*/
|
||||||
|
const PROGRAM_START_YEAR = 2000;
|
||||||
|
|
||||||
|
const ACCENT = "#138ba0";
|
||||||
|
const BACKGROUND = "#ffffff";
|
||||||
|
|
||||||
|
export default function History() {
|
||||||
|
const { items, loading, error, reload } = useHistory();
|
||||||
|
|
||||||
|
// Section renders `content` outside its own max-width wrapper, so the
|
||||||
|
// container lives here. The custom properties bind the timeline to
|
||||||
|
// this section's accent and background — --tl-surface must match
|
||||||
|
// `background` or the rail will show through the year dots.
|
||||||
|
const wrap = (children: React.ReactNode) => (
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-6"
|
||||||
|
style={{ "--tl-accent": ACCENT, "--tl-surface": BACKGROUND } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = wrap(
|
||||||
|
<p className="py-12 text-[#4a6b72]" role="status">
|
||||||
|
Loading the timeline…
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
// Say what failed and offer the one action that might fix it.
|
||||||
|
// A history page that silently renders nothing looks like an
|
||||||
|
// organization with no history.
|
||||||
|
content = wrap(
|
||||||
|
<div className="py-12">
|
||||||
|
<p className="text-[#b3261e]">Couldn’t load the timeline. {error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reload}
|
||||||
|
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = wrap(
|
||||||
|
<HistoryTimeline
|
||||||
|
items={items}
|
||||||
|
decades={HISTORY_DECADES}
|
||||||
|
direction="desc"
|
||||||
|
programStartYear={PROGRAM_START_YEAR}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="History"
|
||||||
|
intro="A near-complete timeline of Next Generation of Unity (NGU), and other Unity Young Adult programs."
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: "timeline",
|
||||||
|
title: "NGU Timeline",
|
||||||
|
blurb:
|
||||||
|
"Open a year to see what happened.",
|
||||||
|
accent: ACCENT,
|
||||||
|
background: BACKGROUND,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
438
src/pages/OrganizationDetail.tsx
Normal file
438
src/pages/OrganizationDetail.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ORGANIZATION DETAIL
|
||||||
|
/regions/:id /chapters/:id /partners/:id /organizations/:id
|
||||||
|
|
||||||
|
One component behind four routes, because organizations are one
|
||||||
|
table. The kind decides which extras render, not which file runs.
|
||||||
|
|
||||||
|
The URL kind is decoration — the slug is what identifies the
|
||||||
|
record — so a request for /chapters/great-lakes when that slug is
|
||||||
|
a region redirects to the canonical path rather than rendering a
|
||||||
|
correct page at a wrong address.
|
||||||
|
|
||||||
|
Leadership arrives flat with team_id and team_name on each row,
|
||||||
|
so grouping costs nothing. `teams` is fetched alongside anyway:
|
||||||
|
a team with no current public members would otherwise be
|
||||||
|
invisible here instead of listed, and its page unreachable.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||||
|
import EventListCards from './sections/EventList-Cards.tsx'
|
||||||
|
import {
|
||||||
|
useOrganization,
|
||||||
|
type Leader,
|
||||||
|
type OrganizationRecord,
|
||||||
|
} from '../lib/useContent.ts'
|
||||||
|
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
|
||||||
|
import { orgLogo, personPhoto } from '../lib/media.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function OrganizationDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const { data: org, loading, error, notFound, reload } = useOrganization(id)
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="organization"
|
||||||
|
backTo="/community"
|
||||||
|
backLabel="Community"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /chapters/x when x is a region: same record, wrong address.
|
||||||
|
//
|
||||||
|
// useLocation, not window.location: the latter is outside the
|
||||||
|
// router's awareness — always "/" under HashRouter, and free to
|
||||||
|
// be stale mid-navigation, either of which turns this into a
|
||||||
|
// redirect loop rather than a one-shot correction.
|
||||||
|
//
|
||||||
|
// Guarded on org.id because a record with no id would redirect to
|
||||||
|
// /chapters/undefined, which is a worse page than the one we're
|
||||||
|
// already on. All four organization routes must be registered or
|
||||||
|
// this redirects somewhere nothing matches.
|
||||||
|
const canonical = org.id ? orgHref(org.id, org.kind) : null
|
||||||
|
if (canonical && decodeURIComponent(pathname) !== canonical) {
|
||||||
|
return <Navigate to={canonical} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = org.color || TEAL
|
||||||
|
const back = orgListHref(org.kind)
|
||||||
|
|
||||||
|
const sections: any[] = [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts org={org} accent={accent} back={back} />
|
||||||
|
|
||||||
|
{org.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={org.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Contact org={org} accent={accent} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const teamBlocks = groupLeadership(org)
|
||||||
|
|
||||||
|
if (teamBlocks.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'leadership',
|
||||||
|
title: 'Who runs it',
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-12">
|
||||||
|
{teamBlocks.map((block) => (
|
||||||
|
<div key={block.key}>
|
||||||
|
<div className="mb-4">
|
||||||
|
{block.teamId ? (
|
||||||
|
<h3 className="text-xl font-bold">
|
||||||
|
<Link
|
||||||
|
to={teamHref(block.teamId)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{block.label}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
block.label && (
|
||||||
|
<h3 className="text-xl font-bold" style={{ color: accent }}>
|
||||||
|
{block.label}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{block.tagline && (
|
||||||
|
<p className="mt-1 text-sm" style={{ color: BODY }}>
|
||||||
|
{block.tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{block.people.length > 0 ? (
|
||||||
|
<PeopleTiles
|
||||||
|
size="lg"
|
||||||
|
accent={block.accent || accent}
|
||||||
|
people={block.people.map((leader) => ({
|
||||||
|
id: leader.person_id,
|
||||||
|
name: leader.display_name,
|
||||||
|
title: leader.title,
|
||||||
|
pronouns: leader.pronouns,
|
||||||
|
public_email: leader.public_email,
|
||||||
|
photo: personPhoto(leader.photo),
|
||||||
|
is_owner: leader.is_owner,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic" style={{ color: BODY }}>
|
||||||
|
No members listed yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'chapters',
|
||||||
|
title: 'Chapters',
|
||||||
|
blurb: org.details.map_note ?? undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{(org.details.chapters ?? []).map((chapter) => (
|
||||||
|
<Link
|
||||||
|
key={chapter.id}
|
||||||
|
to={orgHref(chapter.id, 'chapter')}
|
||||||
|
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: `${accent}40` }}
|
||||||
|
>
|
||||||
|
<Logo file={chapter.logo} name={chapter.name} accent={accent} />
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold" style={{ color: accent }}>
|
||||||
|
{chapter.name}
|
||||||
|
</span>
|
||||||
|
{chapter.location_label && (
|
||||||
|
<span className="block text-sm" style={{ color: BODY }}>
|
||||||
|
{chapter.location_label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.awards.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'awards',
|
||||||
|
title: 'Awards',
|
||||||
|
blurb: `Given by ${org.short_name || org.name}.`,
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
{org.awards.map((award) => (
|
||||||
|
<Link
|
||||||
|
key={award.id}
|
||||||
|
to={`/awards/${award.id}`}
|
||||||
|
className="rounded-lg border bg-white p-5 transition-colors hover:bg-[#f6fbfc]"
|
||||||
|
style={{ borderColor: `${accent}40` }}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold" style={{ color: accent }}>
|
||||||
|
{award.name}
|
||||||
|
</span>
|
||||||
|
{award.description && (
|
||||||
|
<span className="mt-1 block text-sm" style={{ color: BODY }}>
|
||||||
|
{award.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="mt-3 block text-xs uppercase tracking-wide opacity-70" style={{ color: BODY }}>
|
||||||
|
{award.recipient_count === 0
|
||||||
|
? 'No recipients yet'
|
||||||
|
: `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventList-Cards fetches and renders this itself — its own
|
||||||
|
// docstring offers `host` for exactly this case, and a card there
|
||||||
|
// already knows about gradients, logos, past-event collapsing and
|
||||||
|
// the carousel. A second grid here was the same component written
|
||||||
|
// worse.
|
||||||
|
//
|
||||||
|
// `org.events` is still what decides whether the section exists at
|
||||||
|
// all: an organization that has never hosted anything shouldn't
|
||||||
|
// get a heading followed by "events coming soon".
|
||||||
|
if (org.events.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'events',
|
||||||
|
title: 'Gatherings',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<EventListCards
|
||||||
|
host={org.id}
|
||||||
|
view="grid"
|
||||||
|
accent={accent}
|
||||||
|
empty="· Nothing on the calendar right now ·"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageShell title={org.name} intro={org.tagline ?? undefined} sections={sections} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pieces ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function Facts({
|
||||||
|
org,
|
||||||
|
accent,
|
||||||
|
back,
|
||||||
|
}: {
|
||||||
|
org: OrganizationRecord
|
||||||
|
accent: string
|
||||||
|
back: { to: string; label: string }
|
||||||
|
}) {
|
||||||
|
const where =
|
||||||
|
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||||
|
|
||||||
|
{org.kind === 'region' && org.details.scope && (
|
||||||
|
<span style={{ color: BODY }}>{org.details.scope}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.kind === 'chapter' && org.details.region_id && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Part of{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(org.details.region_id, 'region')}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: org.details.region_color || accent }}
|
||||||
|
>
|
||||||
|
{org.details.region_name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.kind === 'chapter' && org.details.meets && (
|
||||||
|
<span style={{ color: BODY }}>Meets {org.details.meets}</span>
|
||||||
|
)}
|
||||||
|
{org.kind === 'chapter' && org.details.started && (
|
||||||
|
<span style={{ color: BODY }}>Since {org.details.started}</span>
|
||||||
|
)}
|
||||||
|
{org.is_online && <span style={{ color: BODY }}>Online</span>}
|
||||||
|
|
||||||
|
<Link to={back.to} className="ml-auto hover:underline" style={{ color: accent }}>
|
||||||
|
{back.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
||||||
|
const hasAny =
|
||||||
|
org.links.length > 0 || org.socials.length > 0 || org.website || org.email
|
||||||
|
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{org.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{[org.website, org.email, ...org.socials]
|
||||||
|
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||||
|
.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logo({
|
||||||
|
file,
|
||||||
|
name,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
file?: string | null
|
||||||
|
name: string
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const src = orgLogo(file)
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <img src={src} alt="" loading="lazy" className="h-10 w-10 shrink-0 object-contain" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leadership → team blocks ────────────────────────────────── */
|
||||||
|
|
||||||
|
type TeamBlock = {
|
||||||
|
key: string
|
||||||
|
teamId: string | null
|
||||||
|
label: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
accent?: string | null
|
||||||
|
people: Leader[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Order comes from `teams` (the admin's sort_order), not from the
|
||||||
|
order people happen to appear in. Three cases to cover:
|
||||||
|
affiliations with no team at all, teams with nobody in them, and
|
||||||
|
people filed under a team that is no longer published. */
|
||||||
|
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
|
||||||
|
const byTeam = new Map<string, Leader[]>()
|
||||||
|
const loose: Leader[] = []
|
||||||
|
|
||||||
|
for (const leader of org.leadership) {
|
||||||
|
if (!leader.team_id) {
|
||||||
|
loose.push(leader)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const list = byTeam.get(leader.team_id)
|
||||||
|
if (list) list.push(leader)
|
||||||
|
else byTeam.set(leader.team_id, [leader])
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: TeamBlock[] = []
|
||||||
|
|
||||||
|
// People who hold a role in the organization without sitting on a
|
||||||
|
// team. Usually the leads. No heading — they are the page.
|
||||||
|
if (loose.length > 0) {
|
||||||
|
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of org.teams) {
|
||||||
|
blocks.push({
|
||||||
|
key: team.id,
|
||||||
|
teamId: team.id,
|
||||||
|
label: team.name,
|
||||||
|
tagline: team.tagline,
|
||||||
|
accent: team.color,
|
||||||
|
people: byTeam.get(team.id) ?? [],
|
||||||
|
})
|
||||||
|
byTeam.delete(team.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever is left is filed under an unpublished team. Its page
|
||||||
|
// isn't reachable, so the heading is plain text — but the people
|
||||||
|
// are real and shouldn't silently vanish from the org.
|
||||||
|
for (const [teamId, people] of byTeam) {
|
||||||
|
blocks.push({
|
||||||
|
key: teamId,
|
||||||
|
teamId: null,
|
||||||
|
label: people[0]?.team_name ?? null,
|
||||||
|
people,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
|
||||||
different filter. The page declares them and does nothing else —
|
different filter. The page declares them and does nothing else —
|
||||||
each section fetches its own slice.
|
each section fetches its own slice.
|
||||||
|
|
||||||
|
Two filters per band now, and they answer different questions:
|
||||||
|
|
||||||
|
section whose gathering it is — the scope. event_sections
|
||||||
|
holds six of these; this page draws three.
|
||||||
|
type what kind of gathering it is. Pinned to "retreat"
|
||||||
|
everywhere on this page, which is what the page is
|
||||||
|
for and what lets "Partner" read as a heading rather
|
||||||
|
than a catch-all.
|
||||||
|
|
||||||
|
Because every band is pinned to one type, EventListCards finds a
|
||||||
|
single kind in each and draws no type chips. Drop the `type` from
|
||||||
|
a band and the chips appear on their own.
|
||||||
|
|
||||||
|
What this page deliberately does not show: local, international
|
||||||
|
and other scopes, and every non-retreat type. Those are reachable
|
||||||
|
from their host's page and by URL, and get a band here when
|
||||||
|
there's enough of them to fill one — see the block at the bottom.
|
||||||
|
|
||||||
To reorder the page, move a line. To add a different kind of
|
To reorder the page, move a line. To add a different kind of
|
||||||
section, add an entry with another Component.
|
section, add an entry with another Component.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||||
|
|
||||||
|
/* Pinned on every band. Named rather than repeated so turning this
|
||||||
|
page into "everything, filtered" later is one deletion. */
|
||||||
|
const RETREATS = { type: "retreat" };
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
id: "national",
|
id: "national",
|
||||||
|
|
@ -23,7 +45,7 @@ const SECTIONS = [
|
||||||
accent: "#138ba0",
|
accent: "#138ba0",
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "national" },
|
props: { section: "national", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "carousel" },
|
views: { ...CARD_VIEWS, default: "carousel" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -33,19 +55,56 @@ const SECTIONS = [
|
||||||
accent: "#aac992",
|
accent: "#aac992",
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "regional" },
|
props: { section: "regional", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "partner",
|
id: "partner",
|
||||||
title: "Partner Events",
|
title: "Partner Events",
|
||||||
blurb: "Events hosted by organizations we collaborate with.",
|
blurb: "Retreats hosted by organizations we collaborate with.",
|
||||||
accent: "#7a5ea8",
|
accent: "#7a5ea8",
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "partner" },
|
props: { section: "partner", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* The other three scopes, ready to uncomment. Each needs an accent
|
||||||
|
and a background of its own — those are presentation and live
|
||||||
|
here, not in event_sections.
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "local",
|
||||||
|
title: "Local Events",
|
||||||
|
blurb: "Hosted by individual chapters.",
|
||||||
|
accent: "#d08a3c",
|
||||||
|
background: "#ffffff",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { section: "local", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "international",
|
||||||
|
title: "International Events",
|
||||||
|
blurb: "Gatherings beyond the US.",
|
||||||
|
accent: "#3c7fd0",
|
||||||
|
background: "#eef9fb",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { section: "international", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
title: "Other Events",
|
||||||
|
blurb: "Everything that doesn't fit the categories above.",
|
||||||
|
accent: "#7a8a8e",
|
||||||
|
background: "#ffffff",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { section: "other", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
|
||||||
|
*/
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RetreatsPage() {
|
export default function RetreatsPage() {
|
||||||
|
|
@ -54,7 +113,7 @@ export default function RetreatsPage() {
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell
|
||||||
title="Retreats"
|
title="Retreats"
|
||||||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
intro="Retreats hosted by Next Generation of Unity, our regions, and our partners throughout the year."
|
||||||
sections={sections}
|
sections={sections}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
155
src/pages/TeamDetail.tsx
Normal file
155
src/pages/TeamDetail.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TEAM DETAIL — /teams/:id
|
||||||
|
|
||||||
|
teams.id is a global primary key rather than scoped to the
|
||||||
|
organization, which is what lets this route be flat: 'ngu-board'
|
||||||
|
can only mean one thing site-wide.
|
||||||
|
|
||||||
|
The roster is not in this page's data. PeopleTiles fetches
|
||||||
|
/teams/:id/people itself, off v_org_leadership, which already
|
||||||
|
decides who counts as current and public. Two requests, both
|
||||||
|
cached for 60s by api.js, and one set of visibility rules.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||||
|
import { useTeam } from '../lib/useContent.ts'
|
||||||
|
import { orgHref } from '../lib/hrefs.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function TeamDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: team, loading, error, notFound, reload } = useTeam(id)
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="team"
|
||||||
|
backTo="/leadership"
|
||||||
|
backLabel="Leadership"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = team.color || TEAL
|
||||||
|
const hasAbout =
|
||||||
|
team.description.length > 0 || team.blocks.length > 0 || team.links.length > 0
|
||||||
|
|
||||||
|
const sections: any[] = []
|
||||||
|
|
||||||
|
if (hasAbout) {
|
||||||
|
sections.push({
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts orgId={team.org.id} orgKind={team.org.kind} orgName={team.org.name} accent={accent} />
|
||||||
|
|
||||||
|
{team.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={team.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{team.links.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{team.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
id: 'members',
|
||||||
|
title: hasAbout ? 'Members' : team.name,
|
||||||
|
accent,
|
||||||
|
background: hasAbout ? '#eef9fb' : '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||||
|
{!hasAbout && (
|
||||||
|
<Facts
|
||||||
|
orgId={team.org.id}
|
||||||
|
orgKind={team.org.kind}
|
||||||
|
orgName={team.org.name}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PeopleTiles
|
||||||
|
size="lg"
|
||||||
|
accent={accent}
|
||||||
|
teams={[{ id: team.id, label: 'Members', accent }]}
|
||||||
|
emptyMessage="Nobody is currently listed on this team."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={team.name} intro={team.tagline ?? undefined} sections={sections} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Facts({
|
||||||
|
orgId,
|
||||||
|
orgKind,
|
||||||
|
orgName,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
orgId: string
|
||||||
|
orgKind?: string | null
|
||||||
|
orgName: string
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
A team of{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(orgId, orgKind)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{orgName}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={orgHref(orgId, orgKind)}
|
||||||
|
className="ml-auto hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Back to {orgName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,15 @@
|
||||||
ADMIN — FEEDBACK TRIAGE
|
ADMIN — FEEDBACK TRIAGE
|
||||||
|
|
||||||
Reads /api/admin/feedback, writes status and notes back through
|
Reads /api/admin/feedback, writes status and notes back through
|
||||||
PATCH. Deliberately a flat list rather than a table: the message
|
PATCH, and deletes through DELETE. Deliberately a flat list
|
||||||
is the content, and messages don't fit in a cell.
|
rather than a table: the message is the content, and messages
|
||||||
|
don't fit in a cell.
|
||||||
|
|
||||||
|
Two capabilities, two ranks. Editors and above can change a
|
||||||
|
status or leave a note; deleting is admin and above, matching
|
||||||
|
requireRole on the server. Both come from the roles ladder
|
||||||
|
rather than an equality check — a superadmin is not role ===
|
||||||
|
"admin", and reading it that way is what hid these controls.
|
||||||
|
|
||||||
Every read passes ttl: 0. The api cache exists for public
|
Every read passes ttl: 0. The api cache exists for public
|
||||||
content that changes weekly; a triage queue two people are
|
content that changes weekly; a triage queue two people are
|
||||||
|
|
@ -13,8 +20,9 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { get, patch, ApiError } from "../../lib/api.js";
|
import { del, get, patch, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||||
|
|
||||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||||
|
|
@ -44,10 +52,11 @@ function locationOf(row) {
|
||||||
|
|
||||||
/* ── One submission ──────────────────────────────────────────── */
|
/* ── One submission ──────────────────────────────────────────── */
|
||||||
|
|
||||||
function FeedbackCard({ row, onChange, canWrite }) {
|
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||||
const [note, setNote] = useState(row.admin_note ?? "");
|
const [note, setNote] = useState(row.admin_note ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const noteDirty = note !== (row.admin_note ?? "");
|
const noteDirty = note !== (row.admin_note ?? "");
|
||||||
|
|
||||||
|
|
@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On success this card unmounts, so there's no finally here:
|
||||||
|
// busy only needs clearing on the path where the row survives.
|
||||||
|
async function remove() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await del(`/admin/feedback/${row.id}`);
|
||||||
|
onRemove(row);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
|
||||||
|
setConfirming(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
|
@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -150,6 +172,52 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deleting is the irreversible option; marking something
|
||||||
|
spam or archived is the habit this defers to. Hence the
|
||||||
|
second click rather than a window.confirm. */}
|
||||||
|
{canRemove && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-[#26454c]">
|
||||||
|
Delete #{row.id} for good? Marking it spam keeps it recoverable.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={remove}
|
||||||
|
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
|
||||||
|
>
|
||||||
|
{busy ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
|
||||||
|
>
|
||||||
|
Keep it
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +238,9 @@ export default function AdminFeedback() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const canWrite = user?.role === "admin";
|
// Minimums, not equality — see lib/roles.ts.
|
||||||
|
const canWrite = roleCanWrite(user);
|
||||||
|
const canRemove = canDelete(user);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (before = null) => {
|
async (before = null) => {
|
||||||
|
|
@ -218,6 +288,16 @@ export default function AdminFeedback() {
|
||||||
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The deleted row is passed whole rather than by id: its status
|
||||||
|
// is what says which tab count to drop.
|
||||||
|
function removeRow(removed) {
|
||||||
|
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||||
|
setCounts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||||
|
|
@ -294,7 +374,9 @@ export default function AdminFeedback() {
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
|
canRemove={canRemove}
|
||||||
onChange={replaceRow}
|
onChange={replaceRow}
|
||||||
|
onRemove={removeRow}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
182
src/pages/admin/AdminHome.tsx
Normal file
182
src/pages/admin/AdminHome.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN HOME
|
||||||
|
|
||||||
|
Where login lands. Two columns: the cards are the navigation —
|
||||||
|
the header deliberately drops its tab row here so the same links
|
||||||
|
aren't drawn twice — and a standing info panel on the right.
|
||||||
|
|
||||||
|
The cards come from adminNav.js, the same list the CMS header
|
||||||
|
reads, so a new entity shows up here the moment it's registered.
|
||||||
|
Forms sit in their own block below: they're submissions coming
|
||||||
|
in rather than content going out, and there'll be more of them
|
||||||
|
than the feedback queue eventually. The Panel block below that
|
||||||
|
only exists for superadmins.
|
||||||
|
|
||||||
|
The right-hand panel is deliberately inert. Nothing here
|
||||||
|
fetches, so the landing page can't be slow or half-broken on
|
||||||
|
arrival; anything live (open feedback count, last-edited
|
||||||
|
record) wants to be a separate component that fails on its own.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
|
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
|
||||||
|
|
||||||
|
/* One card. The title link is stretched over the whole card with
|
||||||
|
`after:absolute`, which makes the card clickable without nesting
|
||||||
|
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
||||||
|
they stay separately clickable. */
|
||||||
|
function NavCard({ item }) {
|
||||||
|
const to = target(item);
|
||||||
|
|
||||||
|
// Drop the child that just repeats the card's own destination —
|
||||||
|
// "All organizations" under Organizations.
|
||||||
|
const extras = (item.children ?? []).filter((child) => child.to !== to);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex flex-col rounded-2xl border border-[#138ba0]/20 bg-white p-5 transition-all hover:border-[#138ba0]/60 hover:shadow-sm">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-[#138ba0]">
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="after:absolute after:inset-0 after:rounded-2xl after:content-['']"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-auto text-[#138ba0] opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.blurb && (
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extras.length > 0 && (
|
||||||
|
<div className="relative z-10 mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-[#138ba0]/10 pt-3 text-sm">
|
||||||
|
{extras.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.to}
|
||||||
|
to={child.to}
|
||||||
|
className="text-[#4a6b72] underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardBlock({ title, blurb, children }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||||
|
{blurb && <p className="text-sm text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelSection({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2.5 text-sm text-[#4a6b72]">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_ADD = [
|
||||||
|
{ to: "/admin/events/new", label: "New event" },
|
||||||
|
{ to: "/admin/people/new", label: "New person" },
|
||||||
|
{ to: "/admin/organizations/new", label: "New organization" },
|
||||||
|
{ to: "/admin/timeline/new", label: "New timeline entry" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminHome() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#138ba0]">
|
||||||
|
{user?.name ? `Welcome back, ${user.name.split(" ")[0]}.` : "Welcome back."}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-[#4a6b72]">Pick a section to work in.</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
{CMS_NAV.map((item) => (
|
||||||
|
<NavCard key={item.label} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBlock title={FORMS_NAV.label} blurb={FORMS_NAV.blurb}>
|
||||||
|
{FORMS_NAV.children.map((form) => (
|
||||||
|
<NavCard key={form.to} item={form} />
|
||||||
|
))}
|
||||||
|
</CardBlock>
|
||||||
|
|
||||||
|
{isSuper(user) && (
|
||||||
|
<CardBlock title="Superadmin" blurb="Only superadmins see this block.">
|
||||||
|
<NavCard item={PANEL_NAV} />
|
||||||
|
</CardBlock>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky so it stays put once the card column outgrows it. */}
|
||||||
|
<aside className="divide-y divide-[#138ba0]/10 rounded-2xl border border-[#138ba0]/20 bg-white lg:sticky lg:top-6">
|
||||||
|
<PanelSection title="Signed in">
|
||||||
|
<p className="font-medium text-[#0f2f36]">{user?.name || user?.email}</p>
|
||||||
|
{user?.name && user?.email && (
|
||||||
|
<p className="mt-0.5 break-all text-xs text-[#4a6b72]/80">{user.email}</p>
|
||||||
|
)}
|
||||||
|
{role && (
|
||||||
|
<span className="mt-2 inline-block rounded-full bg-[#eef9fb] px-2.5 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Start something">
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{QUICK_ADD.map((link) => (
|
||||||
|
<li key={link.to}>
|
||||||
|
<Link
|
||||||
|
to={link.to}
|
||||||
|
className="underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Site">
|
||||||
|
<p className="font-mono text-xs text-[#4a6b72]/80">{SITE_VERSION}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-2 inline-block underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
View the public site ↗
|
||||||
|
</a>
|
||||||
|
</PanelSection>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,59 +5,45 @@
|
||||||
footer site map — none of that belongs around a staff tool, and
|
footer site map — none of that belongs around a staff tool, and
|
||||||
/admin should never appear in navConfig.
|
/admin should never appear in navConfig.
|
||||||
|
|
||||||
The nav is two tiers, the same shape as the public header: a
|
Three areas share this chrome: Home, the CMS and the Panel. The
|
||||||
primary row of the things you'd go looking for, and a subnav of
|
wordmark names whichever one you're in, and from anywhere but
|
||||||
whatever sits under the one you're in. Teams and Awards live
|
Home it's the way back to Home.
|
||||||
under Organizations because that's where they belong
|
|
||||||
conceptually — a team is part of an org, an award is given by
|
|
||||||
one — even though each is its own table and its own page.
|
|
||||||
|
|
||||||
NAV is the single source for the rows, the document title and
|
The tab row is drawn everywhere except Home, where the card grid
|
||||||
which tab lights up. Adding an entity is an entry here plus a
|
is the navigation and drawing both would say the same thing
|
||||||
descriptor; there's no second list to keep in step.
|
twice. Which tabs appear depends on the signed-in user —
|
||||||
|
navFor() drops the superadmin-only ones — but that's cosmetics.
|
||||||
|
The route guard and the API are what actually say no.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
||||||
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
const SITE_TITLE = "NGU Admin CMS";
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
|
import nguLogo from "../../assets/NGU_Logo.svg";
|
||||||
// A group with no `to` of its own opens its first child, so
|
import {
|
||||||
// clicking the word Forms goes somewhere rather than nowhere.
|
ADMIN_HOME,
|
||||||
const NAV = [
|
AREA_TITLES,
|
||||||
{ to: "/admin/events", label: "Events" },
|
areaFor,
|
||||||
{
|
matches,
|
||||||
to: "/admin/organizations",
|
navFor,
|
||||||
label: "Organizations",
|
target,
|
||||||
children: [
|
} from "./adminNav.js";
|
||||||
{ to: "/admin/organizations", label: "All organizations" },
|
|
||||||
{ to: "/admin/teams", label: "Teams" },
|
|
||||||
{ to: "/admin/awards", label: "Awards" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ to: "/admin/people", label: "People" },
|
|
||||||
{
|
|
||||||
label: "Forms",
|
|
||||||
separated: true,
|
|
||||||
children: [{ to: "/admin/feedback", label: "Website feedback" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// A tab owns its own page and everything below it, so editing
|
|
||||||
// /admin/teams/ngu-board keeps Teams lit.
|
|
||||||
const matches = (pathname, to) =>
|
|
||||||
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
|
||||||
|
|
||||||
const target = (item) => item.to ?? item.children?.[0]?.to;
|
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const active = NAV.find(
|
const area = areaFor(pathname);
|
||||||
|
const areaTitle = AREA_TITLES[area];
|
||||||
|
const isHome = area === "home";
|
||||||
|
|
||||||
|
const nav = useMemo(() => navFor(user), [user]);
|
||||||
|
|
||||||
|
const active = nav.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
matches(pathname, item.to) ||
|
matches(pathname, item.to) ||
|
||||||
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||||
|
|
@ -75,9 +61,12 @@ export default function AdminLayout() {
|
||||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const section = activeChild?.label ?? active?.label;
|
// Sections only exist in the CMS. Home and Panel already say
|
||||||
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
|
// what they are in the area title; "Panel | NGU Admin Panel"
|
||||||
}, [active, activeChild, detail]);
|
// would just stutter.
|
||||||
|
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
|
||||||
|
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
|
||||||
|
}, [area, areaTitle, active, activeChild, detail]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logout();
|
await logout();
|
||||||
|
|
@ -86,37 +75,54 @@ export default function AdminLayout() {
|
||||||
|
|
||||||
const subnav = active?.children ?? [];
|
const subnav = active?.children ?? [];
|
||||||
|
|
||||||
|
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f6fbfc]">
|
<div className="flex min-h-screen flex-col bg-[#f6fbfc]">
|
||||||
<header className="border-b border-[#138ba0]/20 bg-white">
|
<header className="border-b border-[#138ba0]/20 bg-white">
|
||||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
|
||||||
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
|
{/* Already home, so nothing to link to. */}
|
||||||
|
{isHome ? (
|
||||||
|
wordmark
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={ADMIN_HOME}
|
||||||
|
className="rounded transition-opacity hover:opacity-70"
|
||||||
|
title="Back to the admin home"
|
||||||
|
>
|
||||||
|
{wordmark}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
{!isHome && (
|
||||||
{NAV.map((item) => (
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<div key={item.label} className="flex items-center gap-6">
|
{nav.map((item) => (
|
||||||
{item.separated && (
|
<div key={item.label} className="flex items-center gap-6">
|
||||||
<span
|
{item.separated && (
|
||||||
aria-hidden="true"
|
<span
|
||||||
className="h-4 w-px bg-[#4a6b72]/25"
|
aria-hidden="true"
|
||||||
/>
|
className="h-4 w-px bg-[#4a6b72]/25"
|
||||||
)}
|
/>
|
||||||
<NavLink
|
)}
|
||||||
to={target(item)}
|
<NavLink
|
||||||
className={
|
to={target(item)}
|
||||||
item === active
|
className={
|
||||||
? "font-semibold text-[#138ba0]"
|
item === active
|
||||||
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
? "font-semibold text-[#138ba0]"
|
||||||
}
|
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||||
>
|
}
|
||||||
{item.label}
|
>
|
||||||
</NavLink>
|
{item.label}
|
||||||
</div>
|
</NavLink>
|
||||||
))}
|
</div>
|
||||||
</nav>
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
|
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
|
||||||
<span>{user?.name || user?.email}</span>
|
<span>
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|
@ -150,11 +156,25 @@ export default function AdminLayout() {
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
{/* flex-1 rather than a fixed height: the footer sits at the
|
||||||
|
bottom of a short page and below the content of a long one,
|
||||||
|
without ever floating over it. */}
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
||||||
<AdminTitleContext.Provider value={titleContext}>
|
<AdminTitleContext.Provider value={titleContext}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AdminTitleContext.Provider>
|
</AdminTitleContext.Provider>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-[#138ba0]/15 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
|
||||||
|
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
|
||||||
|
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
|
||||||
|
{SITE_VERSION}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function AdminLogin() {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const destination = location.state?.from?.pathname ?? "/admin/feedback";
|
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
331
src/pages/admin/AdminPanel.tsx
Normal file
331
src/pages/admin/AdminPanel.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN PANEL
|
||||||
|
|
||||||
|
Superadmin only, guarded by RequireRole on the route and by
|
||||||
|
requireRole("superadmin") on every endpoint it calls. This page
|
||||||
|
assumes neither: it renders whatever the API gives it and shows
|
||||||
|
whatever the API refuses.
|
||||||
|
|
||||||
|
Three blocks, deliberately boring:
|
||||||
|
|
||||||
|
System — what's actually running, for when something is off
|
||||||
|
Content — row counts, the cheapest "is the database there"
|
||||||
|
Accounts — roles, access and live sessions
|
||||||
|
|
||||||
|
The role select is driven by ROLES from lib/roles.ts, so a new
|
||||||
|
rung on the ladder appears here without this file changing. The
|
||||||
|
legend beside it is the same list — four roles is past the
|
||||||
|
point where "Editor" explains itself.
|
||||||
|
|
||||||
|
Every account write signs that person out, which the server
|
||||||
|
does rather than this page. Two things you can't do here: edit
|
||||||
|
your own row, or take the last active superadmin away. Both are
|
||||||
|
enforced server-side and mirrored in the disabled states, so
|
||||||
|
the reason shows up before the click rather than after it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { del, get, patch } from "../../lib/api.js";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||||
|
|
||||||
|
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||||
|
marker that says so. Left alone, browsers read it as local time
|
||||||
|
and last-login drifts by the timezone offset. */
|
||||||
|
function when(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptime(seconds) {
|
||||||
|
if (seconds == null) return "—";
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d) return `${d}d ${h}h`;
|
||||||
|
if (h) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({ title, note, children }) {
|
||||||
|
return (
|
||||||
|
<section className="mt-8 first:mt-0">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||||
|
{note && <p className="text-sm text-[#4a6b72]">{note}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-words text-sm font-medium text-[#0f2f36]">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||||
|
// the row so a failure on one account doesn't blank the table.
|
||||||
|
const [busyId, setBusyId] = useState(null);
|
||||||
|
const [rowError, setRowError] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setError(err.message || "Couldn't load the panel.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
/* Replace the one row the server returns rather than refetching
|
||||||
|
the whole overview — the counts didn't change. */
|
||||||
|
function mergeUser(updated) {
|
||||||
|
setData((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
users: current.users.map((u) => (u.id === updated.id ? updated : u)),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(id, work) {
|
||||||
|
setBusyId(id);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
mergeUser(await work());
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setRowError({ id, message: err.message || "That didn't work." });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeRole = (row, role) =>
|
||||||
|
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||||
|
|
||||||
|
const setActive = (row, is_active) =>
|
||||||
|
run(
|
||||||
|
row.id,
|
||||||
|
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||||
|
);
|
||||||
|
|
||||||
|
const revoke = (row) =>
|
||||||
|
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="py-12 text-[#4a6b72]" role="status">
|
||||||
|
Loading the panel…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="py-12">
|
||||||
|
<p className="text-[#b3261e]">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { system, content, users } = data;
|
||||||
|
const activeSupers = users.filter(
|
||||||
|
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#138ba0]">Panel</h1>
|
||||||
|
<p className="mt-1 text-[#4a6b72]">
|
||||||
|
Accounts and server state. Everything here is superadmin-only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Block title="System">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Stat label="Schema version" value={system.schemaVersion} />
|
||||||
|
<Stat label="API uptime" value={uptime(system.uptimeSeconds)} />
|
||||||
|
<Stat label="Started" value={when(system.startedAt)} />
|
||||||
|
<Stat label="Node" value={system.nodeVersion} />
|
||||||
|
<Stat label="Platform" value={system.platform} />
|
||||||
|
<Stat label="Live sessions" value={system.sessions} />
|
||||||
|
</div>
|
||||||
|
{system.dbPath && (
|
||||||
|
<p className="mt-3 font-mono text-xs text-[#4a6b72]/70">{system.dbPath}</p>
|
||||||
|
)}
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block title="Content" note="Row counts, straight from the tables.">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.map((row) => (
|
||||||
|
<span
|
||||||
|
key={row.label}
|
||||||
|
className="rounded-full border border-[#138ba0]/20 bg-white px-3 py-1 text-sm text-[#4a6b72]"
|
||||||
|
>
|
||||||
|
{row.label}{" "}
|
||||||
|
<strong className="font-semibold text-[#0f2f36]">
|
||||||
|
{row.count ?? "—"}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Accounts"
|
||||||
|
note="Changing a role or disabling an account signs that person out."
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
||||||
|
<table className="w-full min-w-[46rem] text-left text-sm">
|
||||||
|
<thead className="border-b border-[#138ba0]/15 text-xs uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">Account</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Last login</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Sessions</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Access</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#138ba0]/10">
|
||||||
|
{users.map((row) => {
|
||||||
|
const isMe = row.id === me?.id;
|
||||||
|
const lastSuper =
|
||||||
|
row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1;
|
||||||
|
const locked = isMe || lastSuper;
|
||||||
|
const busy = busyId === row.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className={row.is_active ? "" : "bg-[#f6fbfc]"}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-[#0f2f36]">
|
||||||
|
{row.name || row.email}
|
||||||
|
{isMe && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#4a6b72]/70">
|
||||||
|
you
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.name && (
|
||||||
|
<div className="text-xs text-[#4a6b72]/80">{row.email}</div>
|
||||||
|
)}
|
||||||
|
{rowError?.id === row.id && (
|
||||||
|
<div className="mt-1 text-xs text-[#b3261e]">
|
||||||
|
{rowError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
disabled={locked || busy}
|
||||||
|
onChange={(e) => changeRole(row, e.target.value)}
|
||||||
|
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||||
|
title={
|
||||||
|
isMe
|
||||||
|
? "You can't change your own role."
|
||||||
|
: lastSuper
|
||||||
|
? "The last active superadmin can't be demoted."
|
||||||
|
: ROLE_NOTES[row.role]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 text-[#4a6b72]">{when(row.last_login_at)}</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[#4a6b72]">{row.sessions}</span>
|
||||||
|
{row.sessions > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => revoke(row)}
|
||||||
|
className="ml-3 text-xs font-medium text-[#138ba0] underline-offset-4 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={locked || busy}
|
||||||
|
onClick={() => setActive(row, row.is_active ? 0 : 1)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
row.is_active
|
||||||
|
? "border-[#4a6b72]/30 text-[#4a6b72] hover:border-[#b3261e]/50 hover:text-[#b3261e]"
|
||||||
|
: "border-[#138ba0]/40 text-[#138ba0] hover:bg-[#eef9fb]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.is_active ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Each rung adds to the one above it. Worth stating, because
|
||||||
|
"Editor" doesn't tell you where the line falls. */}
|
||||||
|
<dl className="mt-4 grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<div key={role} className="flex gap-2">
|
||||||
|
<dt className="shrink-0 font-medium text-[#0f2f36]">
|
||||||
|
{ROLE_LABELS[role]}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-[#4a6b72]">{ROLE_NOTES[role]}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-[#4a6b72]/80">
|
||||||
|
New accounts are still created with <code>admin-cli.js</code> on the server.
|
||||||
|
</p>
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,16 @@
|
||||||
updated_at column simply never get one, and the 409 path stays
|
updated_at column simply never get one, and the 409 path stays
|
||||||
dormant for them.
|
dormant for them.
|
||||||
|
|
||||||
|
Two capabilities, not one role. An editor may create and update
|
||||||
|
but not delete, so the action bar asks canWrite/canDelete rather
|
||||||
|
than comparing user.role to a string. The comparison this
|
||||||
|
replaced — role === "admin" — locked superadmins out of saving
|
||||||
|
the moment a rank above admin existed, which is what an equality
|
||||||
|
test against a ladder always eventually does.
|
||||||
|
|
||||||
|
None of this is protection. The server refuses the request; this
|
||||||
|
only decides whether to draw a button that would be refused.
|
||||||
|
|
||||||
slugFrom may name one field or several. Most ids are unique
|
slugFrom may name one field or several. Most ids are unique
|
||||||
because the name is: two organizations aren't both called
|
because the name is: two organizations aren't both called
|
||||||
Northwest. Team ids are the exception — teams.id is a global
|
Northwest. Team ids are the exception — teams.id is a global
|
||||||
|
|
@ -37,6 +47,7 @@ import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||||
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||||
|
|
||||||
/* A foreign key refusing to budge is the most common way a save or
|
/* A foreign key refusing to budge is the most common way a save or
|
||||||
|
|
@ -56,7 +67,14 @@ export default function EntityEdit() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const isNew = id === "new";
|
const isNew = id === "new";
|
||||||
const canWrite = user?.role === "admin";
|
const canWrite = atLeast(user, "editor");
|
||||||
|
const canDelete = atLeast(user, "admin");
|
||||||
|
|
||||||
|
// Some entities have no slug: the table assigns an integer id, so
|
||||||
|
// there is nothing to type on create and nothing to compose from
|
||||||
|
// other fields. Timeline entries are the first — an entry that
|
||||||
|
// references an event has no name of its own.
|
||||||
|
const autoId = manifest?.idKind === "auto";
|
||||||
|
|
||||||
// Hoisted above the loading guards: the title hook below is a
|
// Hoisted above the loading guards: the title hook below is a
|
||||||
// hook, so it can't sit after an early return, and it needs the
|
// hook, so it can't sit after an early return, and it needs the
|
||||||
|
|
@ -92,8 +110,9 @@ export default function EntityEdit() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// The heading wants the specific half, not the qualifier: a team
|
// The heading wants the specific half, not the qualifier: a team
|
||||||
// page reads "Board", not "northwest Board".
|
// page reads "Board", not "northwest Board". An entity with no slug
|
||||||
const headingPath = slugPaths[slugPaths.length - 1];
|
// names the field to read instead.
|
||||||
|
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||||
|
|
||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState(null);
|
||||||
const [options, setOptions] = useState({});
|
const [options, setOptions] = useState({});
|
||||||
|
|
@ -120,7 +139,9 @@ export default function EntityEdit() {
|
||||||
// touch", which is wrong for a row that doesn't exist yet.
|
// touch", which is wrong for a row that doesn't exist yet.
|
||||||
// The parent's own fields stay absent on purpose so the
|
// The parent's own fields stay absent on purpose so the
|
||||||
// server's column defaults apply to whatever isn't filled in.
|
// server's column defaults apply to whatever isn't filled in.
|
||||||
const blank = { id: "" };
|
// No id key for an auto entity: the table assigns it, and
|
||||||
|
// sending "" would be an explicit value rather than an absence.
|
||||||
|
const blank = autoId ? {} : { id: "" };
|
||||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||||
setForm(blank);
|
setForm(blank);
|
||||||
baseline.current = JSON.stringify(blank);
|
baseline.current = JSON.stringify(blank);
|
||||||
|
|
@ -139,7 +160,7 @@ export default function EntityEdit() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [manifest, id, isNew, navigate]);
|
}, [manifest, id, isNew, autoId, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|
@ -288,7 +309,19 @@ export default function EntityEdit() {
|
||||||
{isNew ? `New ${manifest.singular}` : heading}
|
{isNew ? `New ${manifest.singular}` : heading}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Slug */}
|
{/* Slug. An auto-id entity has nothing to ask for on create, and
|
||||||
|
nothing editable afterwards — so it gets a plain line rather
|
||||||
|
than a disabled box pretending to be a field. */}
|
||||||
|
{autoId ? (
|
||||||
|
!isNew && (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<p className="text-sm text-[#4a6b72]">
|
||||||
|
{manifest.idLabel} #{form.id}
|
||||||
|
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
<Field
|
<Field
|
||||||
field={{
|
field={{
|
||||||
|
|
@ -317,6 +350,7 @@ export default function EntityEdit() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Field groups */}
|
{/* Field groups */}
|
||||||
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
||||||
|
|
@ -332,6 +366,7 @@ export default function EntityEdit() {
|
||||||
<Field
|
<Field
|
||||||
key={field.path}
|
key={field.path}
|
||||||
field={field}
|
field={field}
|
||||||
|
row={form}
|
||||||
value={getPath(form, field.path)}
|
value={getPath(form, field.path)}
|
||||||
options={options}
|
options={options}
|
||||||
error={errors[field.path]}
|
error={errors[field.path]}
|
||||||
|
|
@ -376,7 +411,7 @@ export default function EntityEdit() {
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{!isNew && (
|
{!isNew && canDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
|
|
@ -388,7 +423,7 @@ export default function EntityEdit() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-[#4a6b72]">
|
<span className="text-sm text-[#4a6b72]">
|
||||||
Read-only: your account can't save changes.
|
Read-only: your account can view this but not change it.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
One component for organizations, events and people. The :entity
|
One component for organizations, events and people. The :entity
|
||||||
route param picks the manifest; nothing here knows what a
|
route param picks the manifest; nothing here knows what a
|
||||||
chapter or a retreat is.
|
chapter or a retreat is.
|
||||||
|
|
||||||
|
The "New X" link follows the same rule as EntityEdit's save
|
||||||
|
button: atLeast(user, "editor"), matching requireRole("editor")
|
||||||
|
on POST /api/admin/:entity. Drawing it is not permission — the
|
||||||
|
server decides — it only avoids offering a click that 403s, and
|
||||||
|
avoids hiding one that wouldn't.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
@ -12,6 +18,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
|
||||||
import { get, ApiError } from "../../lib/api.js";
|
import { get, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||||
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
|
||||||
export default function EntityList() {
|
export default function EntityList() {
|
||||||
const { entity: entityKey } = useParams();
|
const { entity: entityKey } = useParams();
|
||||||
|
|
@ -26,7 +33,12 @@ export default function EntityList() {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
|
|
||||||
const canWrite = user?.role === "admin";
|
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||||
|
// at "editor", so anyone from editor upward may create — and the
|
||||||
|
// equality test this replaced hid the button from superadmins as
|
||||||
|
// well as editors, which is the failure mode an == against a
|
||||||
|
// ladder always produces once a rank is added above it.
|
||||||
|
const canWrite = atLeast(user, "editor");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
|
|
|
||||||
34
src/pages/admin/RequireRole.tsx
Normal file
34
src/pages/admin/RequireRole.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ROLE GUARD — src/pages/admin/RequireRole.tsx
|
||||||
|
|
||||||
|
Sits inside RequireAuth, never instead of it: by the time this
|
||||||
|
renders, the session question has already been answered. All
|
||||||
|
this decides is whether the answer was good enough.
|
||||||
|
|
||||||
|
Same caveat as RequireAuth — this hides the interface, not the
|
||||||
|
data. /api/admin/panel/* is superadmin-only on the server, and
|
||||||
|
that's the part that matters. Without it, a bookmarked URL and
|
||||||
|
a disabled select would be the only thing between a viewer and
|
||||||
|
the account list.
|
||||||
|
|
||||||
|
Bounces to Home rather than showing a "denied" page. Someone
|
||||||
|
who lands here has almost always followed a stale link, and a
|
||||||
|
working page beats an explanation of one.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
import { ADMIN_HOME } from "./adminNav.js";
|
||||||
|
|
||||||
|
export default function RequireRole({ role = "superadmin" }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
// RequireAuth is already showing its own placeholder above this.
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/admin/login" replace />;
|
||||||
|
if (!atLeast(user, role)) return <Navigate to={ADMIN_HOME} replace />;
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
114
src/pages/admin/adminNav.js
Normal file
114
src/pages/admin/adminNav.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN NAVIGATION — src/pages/admin/adminNav.js
|
||||||
|
|
||||||
|
Lifted out of AdminLayout because two things read it now: the
|
||||||
|
header tabs and the card grid on the home page. Adding an entity
|
||||||
|
stays one entry here plus a descriptor — there's still no second
|
||||||
|
list to keep in step, it just isn't inside the layout file
|
||||||
|
any more.
|
||||||
|
|
||||||
|
`blurb` is only read by the home cards. The header ignores it.
|
||||||
|
|
||||||
|
`superOnly` hides an entry from anyone below superadmin. It
|
||||||
|
hides, nothing more: the route still has to be guarded and the
|
||||||
|
API still has to say no. Treating a filtered menu as access
|
||||||
|
control is how you end up with a URL that works.
|
||||||
|
|
||||||
|
Three areas, three titles. The area is derived from the path
|
||||||
|
rather than declared per route, so a page added under
|
||||||
|
/admin/panel/… inherits the right title without registering
|
||||||
|
anything.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { isSuper } from "../../lib/roles.ts";
|
||||||
|
|
||||||
|
export const ADMIN_HOME = "/admin/home";
|
||||||
|
export const ADMIN_PANEL = "/admin/panel";
|
||||||
|
|
||||||
|
export const AREA_TITLES = {
|
||||||
|
home: "NGU Admin Home",
|
||||||
|
cms: "NGU Admin CMS",
|
||||||
|
panel: "NGU Admin Panel",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The CMS tabs. Teams and Awards live under Organizations because
|
||||||
|
that's where they belong conceptually — a team is part of an org,
|
||||||
|
an award is given by one — even though each is its own table and
|
||||||
|
its own page. */
|
||||||
|
export const CMS_NAV = [
|
||||||
|
{
|
||||||
|
to: "/admin/events",
|
||||||
|
label: "Events",
|
||||||
|
blurb: "Retreats, conferences and gatherings, with their sections and rosters.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/organizations",
|
||||||
|
label: "Organizations",
|
||||||
|
blurb: "Regions, chapters and partners — plus the teams and awards they own.",
|
||||||
|
children: [
|
||||||
|
{ to: "/admin/organizations", label: "All organizations" },
|
||||||
|
{ to: "/admin/teams", label: "Teams" },
|
||||||
|
{ to: "/admin/awards", label: "Awards" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/people",
|
||||||
|
label: "People",
|
||||||
|
blurb: "Bios, contact details, affiliations and awards received.",
|
||||||
|
},
|
||||||
|
// Its own tab rather than a child of anything: a timeline entry can
|
||||||
|
// point at an event, an organization, an award, a person or a team,
|
||||||
|
// so filing it under one of them would be arbitrary.
|
||||||
|
{
|
||||||
|
to: "/admin/timeline",
|
||||||
|
label: "Timeline",
|
||||||
|
blurb: "What the history page shows, and the order it shows it in.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Forms are submissions coming in rather than content going out, so
|
||||||
|
they get their own group in the header and their own block on the
|
||||||
|
home page. A group with no `to` of its own opens its first child,
|
||||||
|
so clicking the word Forms goes somewhere rather than nowhere. */
|
||||||
|
export const FORMS_NAV = {
|
||||||
|
label: "Forms",
|
||||||
|
blurb: "Whatever the public site has sent us.",
|
||||||
|
separated: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
to: "/admin/feedback",
|
||||||
|
label: "Website feedback",
|
||||||
|
blurb: "The triage queue for the feedback form.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Accounts and server state, not content — which is why it sits
|
||||||
|
outside the CMS rather than as another tab within it. */
|
||||||
|
export const PANEL_NAV = {
|
||||||
|
to: ADMIN_PANEL,
|
||||||
|
label: "Panel",
|
||||||
|
blurb: "Accounts, sessions and the state of the server.",
|
||||||
|
separated: true,
|
||||||
|
superOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
|
||||||
|
|
||||||
|
/* What this user may see. Call it with the user from useAuth. */
|
||||||
|
export function navFor(user) {
|
||||||
|
return NAV.filter((item) => !item.superOnly || isSuper(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tab owns its own page and everything below it, so editing
|
||||||
|
/admin/teams/ngu-board keeps Teams lit. */
|
||||||
|
export const matches = (pathname, to) =>
|
||||||
|
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||||
|
|
||||||
|
export const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||||
|
|
||||||
|
export function areaFor(pathname) {
|
||||||
|
if (matches(pathname, ADMIN_HOME)) return "home";
|
||||||
|
if (matches(pathname, ADMIN_PANEL)) return "panel";
|
||||||
|
return "cms";
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
import { Link } from "react-router-dom";
|
||||||
|
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||||
|
import { eventHref } from "../../lib/hrefs.ts";
|
||||||
|
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT LIST — CARDS
|
EVENT LIST — CARDS
|
||||||
|
|
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||||
|
|
||||||
<EventListCards section="national" view="carousel" />
|
<EventListCards section="national" view="carousel" />
|
||||||
<EventListCards host="northwest" view="grid" />
|
<EventListCards host="northwest" view="grid" />
|
||||||
|
<EventListCards type={["class", "workshop"]} view="grid" />
|
||||||
|
|
||||||
`view` and `accent` come from the page's section manifest.
|
`view` and `accent` come from the page's section manifest.
|
||||||
|
|
||||||
|
`type` pre-filters the band the way `section` and `host` do. Left
|
||||||
|
off, the band takes every kind it finds and grows a row of chips
|
||||||
|
to narrow by — but only once it holds more than one, so a band of
|
||||||
|
nothing but retreats shows no control at all.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||||
|
|
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* The title is the way into the event's own page.
|
||||||
|
|
||||||
|
A link on the title rather than a wrapper around the whole card:
|
||||||
|
the footer already holds anchors, and an anchor inside an anchor
|
||||||
|
is invalid markup that every browser resolves by guessing. The
|
||||||
|
carousel has the same constraint — it needs the card's click to
|
||||||
|
mean "bring this one to the front" on anything that isn't the
|
||||||
|
active slide. */
|
||||||
|
function TitleLink({ ev, linked, children }) {
|
||||||
|
if (!linked) return <>{children}</>;
|
||||||
|
return (
|
||||||
|
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT CARD — one component, two sizes.
|
EVENT CARD — one component, two sizes.
|
||||||
compact=false → the full card used in the carousel
|
compact=false → the full card used in the carousel
|
||||||
|
|
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
Fields arrive pre-resolved from the API — `color` is the event's
|
Fields arrive pre-resolved from the API — `color` is the event's
|
||||||
own or its host's, `status` is derived from the dates when it
|
own or its host's, `status` is derived from the dates when it
|
||||||
isn't set — so nothing here reimplements those rules.
|
isn't set — so nothing here reimplements those rules.
|
||||||
|
|
||||||
|
`linked` is the one thing a caller turns off: a card on the
|
||||||
|
event's own page shouldn't link to the page it's already on.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
export function Card({
|
export function Card({
|
||||||
ev,
|
ev,
|
||||||
|
|
@ -126,6 +155,8 @@ export function Card({
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
compact = false,
|
compact = false,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
|
linked = true,
|
||||||
|
showType = false,
|
||||||
}) {
|
}) {
|
||||||
const past = ev.status === "past";
|
const past = ev.status === "past";
|
||||||
const color = ev.color || defaultColor;
|
const color = ev.color || defaultColor;
|
||||||
|
|
@ -137,6 +168,19 @@ export function Card({
|
||||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||||||
|
on every card in a row of nothing but retreats is noise, and
|
||||||
|
the card can't tell on its own — it only ever sees one event. */
|
||||||
|
const typeBadge =
|
||||||
|
showType && ev.event_type ? (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
|
||||||
|
style={{ border: `1px solid ${color}`, color }}
|
||||||
|
>
|
||||||
|
{eventTypeLabel(ev.event_type)}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
/* An ordered array, so a card can carry one paragraph or five
|
/* An ordered array, so a card can carry one paragraph or five
|
||||||
without the component changing. */
|
without the component changing. */
|
||||||
const descriptions = (ev.description ?? []).map((text, i) => (
|
const descriptions = (ev.description ?? []).map((text, i) => (
|
||||||
|
|
@ -168,7 +212,12 @@ export function Card({
|
||||||
<div className="ev-grid mb-2">
|
<div className="ev-grid mb-2">
|
||||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||||
<div className="ev-info">
|
<div className="ev-info">
|
||||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
{typeBadge}
|
||||||
|
<h3 className="text-3xl font-900 leading-tight">
|
||||||
|
<TitleLink ev={ev} linked={linked}>
|
||||||
|
{ev.title}
|
||||||
|
</TitleLink>
|
||||||
|
</h3>
|
||||||
{ev.theme && (
|
{ev.theme && (
|
||||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -190,7 +239,12 @@ export function Card({
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
{typeBadge}
|
||||||
|
<h3 className="text-4xl font-900">
|
||||||
|
<TitleLink ev={ev} linked={linked}>
|
||||||
|
{ev.title}
|
||||||
|
</TitleLink>
|
||||||
|
</h3>
|
||||||
{ev.theme && (
|
{ev.theme && (
|
||||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -211,10 +265,32 @@ export function Card({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
{/* Footer — mt-auto pins the whole block to the bottom so
|
||||||
across every card in a grid row. */}
|
buttons line up across every card in a grid row.
|
||||||
{links.length > 0 ? (
|
|
||||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
Three registration states, as before: links to follow, a
|
||||||
|
past event, or an announcement still to come. What's new
|
||||||
|
is that all three end in the same row, because every
|
||||||
|
event now has a page and a past one is often the more
|
||||||
|
worth reading — speakers, awards, what actually
|
||||||
|
happened. The notice is what changes; the way in
|
||||||
|
doesn't. */}
|
||||||
|
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||||
|
{links.length === 0 && (
|
||||||
|
<p className="text-center font-600" style={{ color: accent }}>
|
||||||
|
{past
|
||||||
|
? "This event has concluded — thank you to everyone who joined us!"
|
||||||
|
: igHandle
|
||||||
|
? "Registration has not opened yet, follow our instagram for more details."
|
||||||
|
: "Registration has not opened yet — check back soon for more details."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap justify-center items-center ${
|
||||||
|
compact ? "gap-2" : "gap-3"
|
||||||
|
} ${links.length === 0 ? "mt-4" : ""}`}
|
||||||
|
>
|
||||||
{links.map(item => (
|
{links.map(item => (
|
||||||
<a
|
<a
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|
@ -227,28 +303,17 @@ export function Card({
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
) : past ? (
|
{/* Only when there's nothing to register for and the
|
||||||
<p
|
event hasn't happened — the same condition as before,
|
||||||
className="mt-auto text-center font-600 pt-8"
|
just no longer nested inside that branch. */}
|
||||||
style={{ color: accent }}
|
{igHandle && !past && links.length === 0 && (
|
||||||
>
|
|
||||||
This event has concluded — thank you to everyone who joined us!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
|
||||||
<p className="text-center font-600" style={{ color: accent }}>
|
|
||||||
{igHandle
|
|
||||||
? "Registration has not opened yet, follow our instagram for more details."
|
|
||||||
: "Registration has not opened yet — check back soon for more details."}
|
|
||||||
</p>
|
|
||||||
{igHandle && (
|
|
||||||
<a
|
<a
|
||||||
href={igUrl}
|
href={igUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
|
||||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
compact ? "py-2.5 px-5" : "py-3 px-6"
|
||||||
}`}
|
}`}
|
||||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||||
>
|
>
|
||||||
|
|
@ -256,13 +321,70 @@ export function Card({
|
||||||
{igHandle}
|
{igHandle}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Last, so Register reads first when there is one. */}
|
||||||
|
{linked && (
|
||||||
|
<Link
|
||||||
|
to={eventHref(ev.id)}
|
||||||
|
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||||
|
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||||
|
}`}
|
||||||
|
style={{ border: `1px solid ${color}` }}
|
||||||
|
>
|
||||||
|
Event details
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TYPE FILTER
|
||||||
|
|
||||||
|
Drawn only when a band actually holds more than one kind, so it
|
||||||
|
costs nothing today — every event is a retreat — and appears on
|
||||||
|
its own the first time a class or a workshop lands in that band.
|
||||||
|
Nothing on Retreats.tsx has to be reconfigured for it.
|
||||||
|
|
||||||
|
Chips rather than a select: with four or five options, all of
|
||||||
|
them visible is one tap, and the row reads as what the section
|
||||||
|
contains rather than as a form control.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
export function TypeFilter({ types, active, setActive, accent }) {
|
||||||
|
const chip = on => ({
|
||||||
|
border: `1px solid ${accent}`,
|
||||||
|
background: on ? accent : "transparent",
|
||||||
|
color: on ? "#ffffff" : accent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = (id, label) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
aria-pressed={active === id}
|
||||||
|
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
|
||||||
|
style={chip(active === id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
|
||||||
|
style={{ maxWidth: GRID_MAX }}
|
||||||
|
role="group"
|
||||||
|
aria-label="Filter events by type"
|
||||||
|
>
|
||||||
|
{button("all", "All")}
|
||||||
|
{types.map(entry => button(entry.id, entry.plural))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
TOGGLE — the control for the section heading's action bar
|
TOGGLE — the control for the section heading's action bar
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -323,22 +445,57 @@ export default function EventListCards({
|
||||||
section,
|
section,
|
||||||
host,
|
host,
|
||||||
status,
|
status,
|
||||||
|
type,
|
||||||
view = "carousel",
|
view = "carousel",
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
defaultColor,
|
defaultColor,
|
||||||
empty = "· Events coming soon, stay connected for announcements ·",
|
empty = "· Events coming soon, stay connected for announcements ·",
|
||||||
}) {
|
}) {
|
||||||
const { events, loading, error } = useEvents({ section, host, status });
|
const { events: fetched, loading, error } = useEvents({
|
||||||
|
section,
|
||||||
|
host,
|
||||||
|
status,
|
||||||
|
type,
|
||||||
|
});
|
||||||
const cardColor = defaultColor ?? accent;
|
const cardColor = defaultColor ?? accent;
|
||||||
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [showPast, setShowPast] = useState(false);
|
const [showPast, setShowPast] = useState(false);
|
||||||
|
const [activeType, setActiveType] = useState("all");
|
||||||
|
|
||||||
|
/* What this band holds, which is what the chips offer — not the
|
||||||
|
full list of declared types, three quarters of which would be
|
||||||
|
dead buttons. */
|
||||||
|
const availableTypes = useMemo(
|
||||||
|
() => typesPresent(fetched, EVENT_TYPES),
|
||||||
|
[fetched],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mixed = availableTypes.length > 1;
|
||||||
|
|
||||||
|
const events = useMemo(
|
||||||
|
() =>
|
||||||
|
activeType === "all"
|
||||||
|
? fetched
|
||||||
|
: fetched.filter(e => e.event_type === activeType),
|
||||||
|
[fetched, activeType],
|
||||||
|
);
|
||||||
|
|
||||||
/* Open on the first upcoming event. The list is empty on the
|
/* Open on the first upcoming event. The list is empty on the
|
||||||
first render, so this can't be a useState initialiser — it has
|
first render, so this can't be a useState initialiser — it has
|
||||||
to wait for the data and then run once. Clearing the guard when
|
to wait for the data and then run once. Clearing the guard when
|
||||||
the list empties means a refetch re-seeds. */
|
the list empties means a refetch re-seeds. */
|
||||||
const seeded = useRef(false);
|
const seeded = useRef(false);
|
||||||
|
|
||||||
|
/* Changing the chip is a different list, so the carousel re-seeds
|
||||||
|
on the first upcoming event of that kind rather than holding an
|
||||||
|
index that may now be past the end. Declared before the seed
|
||||||
|
effect so the guard is already clear when it runs. */
|
||||||
|
useEffect(() => {
|
||||||
|
seeded.current = false;
|
||||||
|
setIndex(0);
|
||||||
|
}, [activeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
seeded.current = false;
|
seeded.current = false;
|
||||||
|
|
@ -395,8 +552,24 @@ export default function EventListCards({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
{mixed && (
|
||||||
|
<TypeFilter
|
||||||
|
types={availableTypes}
|
||||||
|
active={activeType}
|
||||||
|
setActive={setActiveType}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
notice(empty)
|
/* Two different empties. Nothing scheduled is news; nothing
|
||||||
|
of the kind you just picked is a filter you can undo, and
|
||||||
|
the chips are still on screen to undo it with. */
|
||||||
|
notice(
|
||||||
|
activeType === "all"
|
||||||
|
? empty
|
||||||
|
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
|
||||||
|
)
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||||
|
|
@ -412,6 +585,7 @@ export default function EventListCards({
|
||||||
defaultColor={cardColor}
|
defaultColor={cardColor}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
compact
|
compact
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -450,6 +624,7 @@ export default function EventListCards({
|
||||||
accent={accent}
|
accent={accent}
|
||||||
compact
|
compact
|
||||||
interactive={showPast}
|
interactive={showPast}
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,6 +689,7 @@ export default function EventListCards({
|
||||||
defaultColor={cardColor}
|
defaultColor={cardColor}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
interactive={active}
|
interactive={active}
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
defaultOpenYears,
|
||||||
|
groupTimeline,
|
||||||
|
groupYears,
|
||||||
|
partitionByDate,
|
||||||
|
type DecadeMeta,
|
||||||
|
type SortDirection,
|
||||||
|
type TimelineItem,
|
||||||
|
} from '../../../lib/timeline'
|
||||||
|
import TimelineDecade from './TimelineDecade'
|
||||||
|
import TimelineUpcoming from './TimelineUpcoming'
|
||||||
|
import './timeline.css'
|
||||||
|
|
||||||
|
const PARAM = 'y'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: TimelineItem[]
|
||||||
|
decades: DecadeMeta[]
|
||||||
|
direction?: SortDirection
|
||||||
|
/** Decades ending before this year get the pre-program treatment. */
|
||||||
|
programStartYear?: number
|
||||||
|
fillGaps?: boolean
|
||||||
|
/** Injectable for tests and for pinning a date in a screenshot. */
|
||||||
|
now?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryTimeline({
|
||||||
|
items,
|
||||||
|
decades,
|
||||||
|
direction = 'desc',
|
||||||
|
programStartYear = 2002,
|
||||||
|
fillGaps = true,
|
||||||
|
now,
|
||||||
|
}: Props) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
// Upcoming vs recorded is a function of the clock, so an event crosses
|
||||||
|
// over on its own date with nothing to flip.
|
||||||
|
const { upcoming, past } = useMemo(
|
||||||
|
() => partitionByDate(items, now ?? new Date()),
|
||||||
|
[items, now],
|
||||||
|
)
|
||||||
|
|
||||||
|
const upcomingYears = useMemo(
|
||||||
|
() => groupYears(upcoming, { direction }),
|
||||||
|
[upcoming, direction],
|
||||||
|
)
|
||||||
|
|
||||||
|
const grouped = useMemo(
|
||||||
|
() => groupTimeline(past, decades, { direction, programStartYear }),
|
||||||
|
[past, decades, direction, programStartYear],
|
||||||
|
)
|
||||||
|
|
||||||
|
// `?y=2014,2022` deep-links straight to open years. Absence of the
|
||||||
|
// param means "not chosen yet", so fall back to the most recent
|
||||||
|
// featured year; an empty param means everything was collapsed on
|
||||||
|
// purpose.
|
||||||
|
const openYears = useMemo(() => {
|
||||||
|
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
|
||||||
|
const raw = searchParams.get(PARAM) ?? ''
|
||||||
|
return new Set(
|
||||||
|
raw
|
||||||
|
.split(',')
|
||||||
|
.map((part) => Number(part.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n)),
|
||||||
|
)
|
||||||
|
}, [searchParams, grouped])
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(next: Set<number>) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
|
||||||
|
// replace: toggling shouldn't fill the back button with history.
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleYear = useCallback(
|
||||||
|
(year: number) => {
|
||||||
|
const next = new Set(openYears)
|
||||||
|
if (next.has(year)) next.delete(year)
|
||||||
|
else next.add(year)
|
||||||
|
commit(next)
|
||||||
|
},
|
||||||
|
[openYears, commit],
|
||||||
|
)
|
||||||
|
|
||||||
|
const allYears = useMemo(() => {
|
||||||
|
const years = grouped.flatMap((decade) =>
|
||||||
|
decade.years.filter((y) => y.count > 0).map((y) => y.year),
|
||||||
|
)
|
||||||
|
return [...years, ...upcomingYears.map((y) => y.year)]
|
||||||
|
}, [grouped, upcomingYears])
|
||||||
|
|
||||||
|
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
|
||||||
|
|
||||||
|
if (grouped.length === 0 && upcomingYears.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="ngu-tl">
|
||||||
|
<p className="ngu-tl__emptyState">
|
||||||
|
The timeline is empty. Add a milestone to start the record.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ngu-tl">
|
||||||
|
<div className="ngu-tl__toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__toolbarBtn"
|
||||||
|
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
|
||||||
|
>
|
||||||
|
{allOpen ? 'Collapse all years' : 'Expand all years'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimelineUpcoming
|
||||||
|
years={upcomingYears}
|
||||||
|
count={upcoming.length}
|
||||||
|
direction={direction}
|
||||||
|
openYears={openYears}
|
||||||
|
onToggleYear={toggleYear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{grouped.map((decade) => (
|
||||||
|
<TimelineDecade
|
||||||
|
key={decade.decade}
|
||||||
|
decade={decade}
|
||||||
|
openYears={openYears}
|
||||||
|
onToggleYear={toggleYear}
|
||||||
|
direction={direction}
|
||||||
|
fillGaps={fillGaps}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
decadeLabel,
|
||||||
|
withGapYears,
|
||||||
|
type GroupedDecade,
|
||||||
|
type SortDirection,
|
||||||
|
} from '../../../lib/timeline'
|
||||||
|
import TimelineYear from './TimelineYear'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
decade: GroupedDecade
|
||||||
|
openYears: Set<number>
|
||||||
|
onToggleYear: (year: number) => void
|
||||||
|
direction?: SortDirection
|
||||||
|
/** Show years with no records as muted nodes, so gaps stay visible. */
|
||||||
|
fillGaps?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineDecade({
|
||||||
|
decade,
|
||||||
|
openYears,
|
||||||
|
onToggleYear,
|
||||||
|
direction = 'desc',
|
||||||
|
fillGaps = true,
|
||||||
|
}: Props) {
|
||||||
|
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
|
||||||
|
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
|
||||||
|
>
|
||||||
|
<header className="ngu-tl__decadeHead">
|
||||||
|
<span className="ngu-tl__decadeNum" aria-hidden="true">
|
||||||
|
{decadeLabel(decade.decade)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
|
||||||
|
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
|
||||||
|
{decade.title}
|
||||||
|
</h3>
|
||||||
|
{decade.tagline ? (
|
||||||
|
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
|
||||||
|
) : null}
|
||||||
|
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
|
||||||
|
{decade.preProgram ? (
|
||||||
|
<p className="ngu-tl__gapNote">
|
||||||
|
<span aria-hidden="true">◌</span>
|
||||||
|
Before the program — kept for context, not part of our record
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{years.map((year) => (
|
||||||
|
<TimelineYear
|
||||||
|
key={year.year}
|
||||||
|
year={year}
|
||||||
|
open={openYears.has(year.year)}
|
||||||
|
onToggle={onToggleYear}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
|
||||||
|
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
|
||||||
|
|
||||||
|
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
|
||||||
|
milestone: 'Milestone',
|
||||||
|
event: 'Event',
|
||||||
|
organization: 'Organization',
|
||||||
|
award: 'Award',
|
||||||
|
people: 'People',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day number only — the month node above already carries the month. */
|
||||||
|
function dayMarker(item: TimelineItem): string {
|
||||||
|
if (item.precision !== 'day') return ''
|
||||||
|
const day = Number(item.date.split('-')[2])
|
||||||
|
return Number.isFinite(day) ? String(day) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Featured entries sit above the month nodes, so they spell out their date. */
|
||||||
|
function fullMarker(item: TimelineItem): string {
|
||||||
|
const [, m, d] = item.date.split('-')
|
||||||
|
if (item.precision === 'year' || !m) return 'Date unrecorded'
|
||||||
|
const month = MONTH_LABELS[Number(m) - 1] ?? ''
|
||||||
|
if (item.precision === 'month' || !d) return month
|
||||||
|
return `${month} ${Number(d)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initials stand in when a person has no photo on file. */
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonChip({ person }: { person: PersonRef }) {
|
||||||
|
const src = photoSrc(person.photo)
|
||||||
|
return (
|
||||||
|
<li className="ngu-tl__person">
|
||||||
|
{src ? (
|
||||||
|
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
|
||||||
|
{initials(person.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ngu-tl__personName">
|
||||||
|
{person.name}
|
||||||
|
{person.title ? (
|
||||||
|
<span className="ngu-tl__personTitle">{person.title}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: TimelineItem
|
||||||
|
/** Featured entries render larger and lead the year. */
|
||||||
|
variant?: 'inline' | 'featured'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
|
||||||
|
const featured = variant === 'featured'
|
||||||
|
const href = hrefFor(item)
|
||||||
|
const logo = logoSrc(item)
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
'ngu-tl__entry',
|
||||||
|
featured ? 'ngu-tl__entry--featured' : '',
|
||||||
|
href ? 'ngu-tl__entry--linked' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const marker = (
|
||||||
|
<span className="ngu-tl__entryDay">
|
||||||
|
{featured ? fullMarker(item) : dayMarker(item)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<span className="ngu-tl__entryBody">
|
||||||
|
<span className="ngu-tl__entryTitle">
|
||||||
|
{logo ? (
|
||||||
|
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
|
||||||
|
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
// The people list sits outside the link: each name is its own
|
||||||
|
// destination, and nesting anchors is invalid markup anyway.
|
||||||
|
const roster =
|
||||||
|
item.kind === 'people' && item.people && item.people.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
|
||||||
|
>
|
||||||
|
{item.team ? (
|
||||||
|
<span className="ngu-tl__rosterTeam">
|
||||||
|
{item.team.name}
|
||||||
|
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<ul className="ngu-tl__people">
|
||||||
|
{item.people.map((person) => (
|
||||||
|
<PersonChip key={person.id} person={person} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
|
||||||
|
// An explicit href may point off-site; Link is for in-app routes only.
|
||||||
|
const external = !!href && /^(https?:)?\/\//.test(href)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="ngu-tl__entryWrap">
|
||||||
|
{href && external ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
aria-label={label}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
) : href ? (
|
||||||
|
<Link to={href} className={className} aria-label={label}>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={className}>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{roster}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
|
||||||
|
import TimelineYear from './TimelineYear'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
years: GroupedYear[]
|
||||||
|
count: number
|
||||||
|
direction?: SortDirection
|
||||||
|
openYears: Set<number>
|
||||||
|
onToggleYear: (year: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled but not yet happened. Sits above the most recent decade and
|
||||||
|
* opens upward: the toggle is first in the DOM and the list is rendered
|
||||||
|
* after it, with `flex-direction: column-reverse` flipping the visual
|
||||||
|
* order. That keeps the button anchored next to the decade marker
|
||||||
|
* instead of drifting up the page as items appear.
|
||||||
|
*
|
||||||
|
* Membership is decided by date, not by a flag — see partitionByDate.
|
||||||
|
* An event moves into the record on its own, with nothing to update.
|
||||||
|
*/
|
||||||
|
export default function TimelineUpcoming({
|
||||||
|
years,
|
||||||
|
count,
|
||||||
|
direction = 'desc',
|
||||||
|
openYears,
|
||||||
|
onToggleYear,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
|
||||||
|
aria-label="Upcoming"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__upcomingBtn"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
|
||||||
|
<span className="ngu-tl__upcomingLabel">
|
||||||
|
{open
|
||||||
|
? 'Hide what’s scheduled'
|
||||||
|
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
|
||||||
|
</span>
|
||||||
|
<span className="ngu-tl__chevron" aria-hidden="true">
|
||||||
|
{open ? '▾' : '▴'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="ngu-tl__upcomingList">
|
||||||
|
{years.map((year) => (
|
||||||
|
<TimelineYear
|
||||||
|
key={year.year}
|
||||||
|
year={year}
|
||||||
|
open={openYears.has(year.year)}
|
||||||
|
onToggle={onToggleYear}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/pages/sections/history/TimelineYear.tsx
Normal file
90
src/pages/sections/history/TimelineYear.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { GroupedYear } from '../../../lib/timeline'
|
||||||
|
import TimelineEntry from './TimelineEntry'
|
||||||
|
|
||||||
|
/** Dot diameter scales with volume, so the rail reads as a density chart. */
|
||||||
|
function dotSize(count: number): string {
|
||||||
|
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
year: GroupedYear
|
||||||
|
open: boolean
|
||||||
|
onToggle: (year: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineYear({ year, open, onToggle }: Props) {
|
||||||
|
const empty = year.count === 0
|
||||||
|
const panelId = `ngu-tl-year-${year.year}`
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
'ngu-tl__year',
|
||||||
|
year.featured ? 'ngu-tl__year--featured' : '',
|
||||||
|
open ? 'ngu-tl__year--open' : '',
|
||||||
|
empty ? 'ngu-tl__year--empty' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__yearBtn"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => onToggle(year.year)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="ngu-tl__dot"
|
||||||
|
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="ngu-tl__yearLabel">{year.year}</span>
|
||||||
|
<span className="ngu-tl__yearCount">
|
||||||
|
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
empty ? (
|
||||||
|
<div className="ngu-tl__empty" id={panelId}>
|
||||||
|
Nothing recorded for {year.year} yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ngu-tl__panel" id={panelId}>
|
||||||
|
{year.featuredItems.length > 0 ? (
|
||||||
|
<ul className="ngu-tl__featured ngu-tl__list">
|
||||||
|
{year.featuredItems.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} variant="featured" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{year.months.map((month) => (
|
||||||
|
<section className="ngu-tl__month" key={month.month}>
|
||||||
|
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
|
||||||
|
<ul className="ngu-tl__list">
|
||||||
|
{month.items.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{year.undated.length > 0 ? (
|
||||||
|
<section className="ngu-tl__month ngu-tl__undated">
|
||||||
|
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
|
||||||
|
<ul className="ngu-tl__list">
|
||||||
|
{year.undated.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
663
src/pages/sections/history/timeline.css
Normal file
663
src/pages/sections/history/timeline.css
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
/**
|
||||||
|
* History timeline — scoped under .ngu-tl.
|
||||||
|
*
|
||||||
|
* Everything themeable is a custom property with a fallback, so rewiring to the
|
||||||
|
* site tokens is a one-block edit at the top. Typefaces are deliberately not
|
||||||
|
* set: the timeline inherits the page's type and only controls scale, weight
|
||||||
|
* and tracking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ngu-tl {
|
||||||
|
/* Defaults match the site palette; History.tsx overrides accent and
|
||||||
|
surface per section so the rail sits on the right background. */
|
||||||
|
--tl-ink: #16343a;
|
||||||
|
--tl-muted: #4a6b72;
|
||||||
|
--tl-faint: #8aa7ad;
|
||||||
|
--tl-rail: #cfe4e8;
|
||||||
|
--tl-surface: #ffffff;
|
||||||
|
--tl-accent: #138ba0;
|
||||||
|
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
|
||||||
|
|
||||||
|
--tl-gutter: 5rem;
|
||||||
|
--tl-rail-x: 1.75rem;
|
||||||
|
--tl-rail-w: 2px;
|
||||||
|
|
||||||
|
color: var(--tl-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ngu-tl {
|
||||||
|
--tl-gutter: 3rem;
|
||||||
|
--tl-rail-x: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Decade block ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__decade {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rail. One per decade block so the pre-program run can change style
|
||||||
|
without breaking the line's continuity. */
|
||||||
|
.ngu-tl__decade::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tl-rail-x);
|
||||||
|
width: var(--tl-rail-w);
|
||||||
|
background: var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre::before {
|
||||||
|
background: none;
|
||||||
|
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Last decade fades out rather than stopping dead. */
|
||||||
|
.ngu-tl__decade:last-child::before {
|
||||||
|
bottom: 2rem;
|
||||||
|
mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre {
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Decade header ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__decadeHead {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
padding-block: 3.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
|
||||||
|
padding-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numeral breaks the rail. */
|
||||||
|
.ngu-tl__decadeNum {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
|
||||||
|
padding-block: 0.35rem;
|
||||||
|
background: var(--tl-surface);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.1;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: sideways;
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre .ngu-tl__decadeNum {
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeTitle {
|
||||||
|
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.018em;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 22ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeTagline {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 54ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeBlurb {
|
||||||
|
margin: 0.875rem 0 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__gapNote {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px dashed var(--tl-rail);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Year node ———————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__year {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 0.6875rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
|
||||||
|
scrolling the page gives you the organization's density at a glance. */
|
||||||
|
.ngu-tl__dot {
|
||||||
|
position: relative;
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
|
||||||
|
width: var(--tl-dot, 8px);
|
||||||
|
height: var(--tl-dot, 8px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tl-rail);
|
||||||
|
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||||
|
transition: background-color 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:hover .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--featured .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--featured .ngu-tl__dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -5px;
|
||||||
|
border: 1.5px solid var(--tl-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--open .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre .ngu-tl__dot {
|
||||||
|
background: var(--tl-surface);
|
||||||
|
border: 1.5px solid var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearLabel {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 550;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition: color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearCount {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--empty .ngu-tl__yearLabel {
|
||||||
|
color: var(--tl-faint);
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Expanded panel ——————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__panel {
|
||||||
|
padding: 0.25rem 0 1.5rem var(--tl-gutter);
|
||||||
|
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ngu-tl-reveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__month {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__month:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__monthLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__monthLabel::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--tl-rail);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__undated .ngu-tl__monthLabel {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Entry ————————————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:hover {
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryDay {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
padding-top: 0.1875rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryMeta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryBlurb {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
|
||||||
|
calm and the type still carries the hierarchy. */
|
||||||
|
.ngu-tl__kind {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: 0.0625rem;
|
||||||
|
background: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--milestone {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--event {
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--award {
|
||||||
|
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--person {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Featured entries ————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__featured {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-left: 2px solid var(--tl-accent);
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__entryTitle {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.008em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__entryDay {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry--featured:hover {
|
||||||
|
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Empty state ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__empty {
|
||||||
|
padding: 0.5rem 0 1.25rem var(--tl-gutter);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ngu-tl *,
|
||||||
|
.ngu-tl *::before,
|
||||||
|
.ngu-tl *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Toolbar + utilities ————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid var(--tl-rail);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn:hover {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
border-color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__emptyState {
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Upcoming block ——————————————————————————————————————————
|
||||||
|
Opens upward: the toggle is first in the DOM, column-reverse puts the
|
||||||
|
list above it. Faded because none of it has happened yet. */
|
||||||
|
|
||||||
|
.ngu-tl__upcoming {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcoming::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tl-rail-x);
|
||||||
|
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn:hover {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingDot {
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px dashed var(--tl-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingLabel {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__chevron {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything above the toggle is provisional, and reads that way. */
|
||||||
|
.ngu-tl__upcomingList {
|
||||||
|
opacity: 0.62;
|
||||||
|
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingList:hover,
|
||||||
|
.ngu-tl__upcomingList:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingList .ngu-tl__dot {
|
||||||
|
background: var(--tl-surface);
|
||||||
|
border: 1.5px dashed var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ngu-tl-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Event / org / award logo ————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__logo {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: -0.1875rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__logo {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
vertical-align: -0.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— People roster ——————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__entryWrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__roster {
|
||||||
|
padding: 0.125rem 0 0.5rem 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__roster--featured {
|
||||||
|
padding: 0.5rem 1.125rem 1rem;
|
||||||
|
border-left: 2px solid var(--tl-accent);
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
border-radius: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__rosterTeam {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__people {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem 1rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personPhoto {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personPhoto--blank {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personName {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Kind markers, continued ————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__kind--organization {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--people {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryBody {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,9 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type SiteConfiguration = {
|
type SiteConfiguration = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue