v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
200
server/src/admin-cli.js
Normal file
200
server/src/admin-cli.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN ACCOUNT CLI
|
||||||
|
|
||||||
|
The only way an account comes into existence. Run it on the box,
|
||||||
|
against the live database:
|
||||||
|
|
||||||
|
cd /srv/ngu-api
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
|
||||||
|
|
||||||
|
add takes --role=viewer for read-only, --name="Full Name".
|
||||||
|
Press enter at the password prompt and it generates one and
|
||||||
|
prints it once.
|
||||||
|
|
||||||
|
Changing or disabling a password also drops that person's live
|
||||||
|
sessions, so "disable" takes effect now rather than in 30 days.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { openDatabase, migrate } from "./db.js";
|
||||||
|
import { hashPassword, destroyAllSessionsFor } from "./auth.js";
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 12;
|
||||||
|
|
||||||
|
/* ── Prompt, with echo suppressed for secrets ────────────────── */
|
||||||
|
|
||||||
|
function ask(question, { hidden = false } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
terminal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.muted = false;
|
||||||
|
rl._writeToOutput = function (text) {
|
||||||
|
if (!rl.muted) rl.output.write(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
if (hidden) rl.output.write("\n");
|
||||||
|
rl.close();
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.muted = hidden;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPassword() {
|
||||||
|
const first = await ask("Password (enter to generate): ", { hidden: true });
|
||||||
|
|
||||||
|
if (first === "") {
|
||||||
|
const generated = randomBytes(12).toString("base64url");
|
||||||
|
console.log(`\nGenerated password: ${generated}`);
|
||||||
|
console.log("Copy it now — it isn't stored anywhere readable.\n");
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.length < MIN_PASSWORD) {
|
||||||
|
fail(`Password must be at least ${MIN_PASSWORD} characters.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = await ask("Again: ", { hidden: true });
|
||||||
|
if (first !== second) fail("Passwords didn't match.");
|
||||||
|
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`✗ ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Commands ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function findUser(db, email) {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?")
|
||||||
|
.get(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(db, email, flags) {
|
||||||
|
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
|
||||||
|
|
||||||
|
const role = flags.role ?? "admin";
|
||||||
|
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
|
||||||
|
|
||||||
|
const password = await readPassword();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO admin_users (email, name, password_hash, role)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
).run(email, flags.name ?? null, hashPassword(password), role);
|
||||||
|
|
||||||
|
console.log(`✓ ${email} created as ${role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passwd(db, email) {
|
||||||
|
const user = findUser(db, email);
|
||||||
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
const password = await readPassword();
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run(
|
||||||
|
hashPassword(password),
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
destroyAllSessionsFor(db, user.id);
|
||||||
|
|
||||||
|
console.log(`✓ password changed for ${email}, existing sessions ended`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(db, email, active) {
|
||||||
|
const user = findUser(db, email);
|
||||||
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
|
||||||
|
active ? 1 : 0,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (!active) destroyAllSessionsFor(db, user.id);
|
||||||
|
|
||||||
|
console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(db) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.email, u.name, u.role, u.is_active, 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.email`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const state = r.is_active ? r.role : "disabled";
|
||||||
|
const seen = r.last_login_at ?? "never";
|
||||||
|
console.log(
|
||||||
|
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Entry ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
|
|
||||||
|
const flags = {};
|
||||||
|
const positional = [];
|
||||||
|
for (const arg of rest) {
|
||||||
|
const match = /^--([^=]+)=(.*)$/.exec(arg);
|
||||||
|
if (match) flags[match[1]] = match[2];
|
||||||
|
else positional.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = positional[0]?.trim().toLowerCase();
|
||||||
|
|
||||||
|
const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db");
|
||||||
|
migrate(db); // so a fresh database gets the tables before we use them
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case "add":
|
||||||
|
if (!email) fail("Usage: admin-cli.js add email@example.com");
|
||||||
|
await add(db, email, flags);
|
||||||
|
break;
|
||||||
|
case "passwd":
|
||||||
|
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
|
||||||
|
await passwd(db, email);
|
||||||
|
break;
|
||||||
|
case "disable":
|
||||||
|
setActive(db, email, false);
|
||||||
|
break;
|
||||||
|
case "enable":
|
||||||
|
setActive(db, email, true);
|
||||||
|
break;
|
||||||
|
case "list":
|
||||||
|
list(db);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("Commands: add, passwd, disable, enable, list");
|
||||||
|
process.exit(command ? 1 : 0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
533
server/src/admin-crud.js
Normal file
533
server/src/admin-crud.js
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN CRUD ENGINE
|
||||||
|
|
||||||
|
Reads a descriptor from admin-schema.js and does the SQL. No
|
||||||
|
entity names appear in this file.
|
||||||
|
|
||||||
|
Writes run inside tx() so a parent, its side table, and every
|
||||||
|
child collection either all land or none do. Children are
|
||||||
|
replaced wholesale rather than diffed: the client sends the list
|
||||||
|
it wants to exist, the engine deletes and reinserts in array
|
||||||
|
order, and sort_order becomes the index. That makes drag-to-
|
||||||
|
reorder free and removes a whole class of "which row is this"
|
||||||
|
bugs, at the cost of churning autoincrement ids — which is fine
|
||||||
|
precisely because nothing references them.
|
||||||
|
|
||||||
|
A column that isn't present in the payload at all is left out of
|
||||||
|
the statement entirely, so the table's DEFAULT applies on insert
|
||||||
|
and the existing value survives on update. A column present but
|
||||||
|
empty ("" or null) is an explicit clear and writes NULL. The
|
||||||
|
difference matters: sending NULL for every unmentioned column is
|
||||||
|
what turns a missing form field into a NOT NULL constraint
|
||||||
|
failure instead of a default.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { tx } from "./db.js";
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(status, message, fields) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.fields = fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/* Not a value the caller can ever send, so it can mean "leave this
|
||||||
|
column out of the statement" without colliding with real data. */
|
||||||
|
const OMIT = Symbol("omit");
|
||||||
|
|
||||||
|
/* ── Coercion ──────────────────────────────────────────────────
|
||||||
|
SQLite STRICT tables reject a type mismatch at the wall, but
|
||||||
|
the error it throws is unreadable. Everything is converted and
|
||||||
|
checked here so failures come back as named fields.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function coerceValue(column, raw, errors, prefix = "") {
|
||||||
|
const key = `${prefix}${column.name}`;
|
||||||
|
|
||||||
|
// Absent from the payload. Fall back to the descriptor default
|
||||||
|
// if it declares one, otherwise let the column's own DEFAULT do
|
||||||
|
// the work — which needs the column omitted, not nulled.
|
||||||
|
if (raw === undefined) {
|
||||||
|
if (column.default !== undefined) return column.default;
|
||||||
|
if (column.required) errors[key] = "Required.";
|
||||||
|
return OMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the blank check: an unchecked box legitimately arrives
|
||||||
|
// as false, "", or null, and all of those mean 0, not NULL.
|
||||||
|
if (column.type === "bool") {
|
||||||
|
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === null || raw === "") {
|
||||||
|
if (column.required) {
|
||||||
|
errors[key] = "Required.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The table refuses NULL but declares a default: clearing the
|
||||||
|
// field means "use the default", not "write NULL".
|
||||||
|
if (column.notNullable) return OMIT;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (column.type) {
|
||||||
|
case "int": {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
|
||||||
|
return Number.isInteger(n) ? n : null;
|
||||||
|
}
|
||||||
|
case "real": {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
case "enum": {
|
||||||
|
const value = String(raw);
|
||||||
|
if (!column.values.includes(value)) {
|
||||||
|
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
const value = String(raw).trim();
|
||||||
|
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
|
||||||
|
return ISO_DATE.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const value = String(raw).trim();
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceRow(columns, data, { prefix = "" } = {}) {
|
||||||
|
const errors = {};
|
||||||
|
const values = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
const value = coerceValue(column, data?.[column.name], errors, prefix);
|
||||||
|
if (value !== OMIT) values[column.name] = value;
|
||||||
|
}
|
||||||
|
return { values, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
|
||||||
|
|
||||||
|
/* ── Read ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function listRows(db, entity, query = {}) {
|
||||||
|
const { columns, filters, search, order } = entity.list;
|
||||||
|
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
for (const name of filters) {
|
||||||
|
const value = query[name];
|
||||||
|
if (value === undefined || value === "" || value === "all") continue;
|
||||||
|
where.push(`${name} = ?`);
|
||||||
|
params.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.q) {
|
||||||
|
const term = `%${String(query.q).slice(0, 100)}%`;
|
||||||
|
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
|
||||||
|
params.push(...search.map(() => term));
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Number(query.limit) || 200, 500);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${columns.join(", ")}
|
||||||
|
FROM ${entity.table}
|
||||||
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
|
ORDER BY ${order}
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...params, limit);
|
||||||
|
|
||||||
|
return { rows, total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRow(db, entity, id) {
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) throw new HttpError(404, "Not found.");
|
||||||
|
|
||||||
|
for (const ext of entity.extensions ?? []) {
|
||||||
|
row[ext.key] =
|
||||||
|
db
|
||||||
|
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
|
||||||
|
.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of entity.children ?? []) {
|
||||||
|
row[child.key] = readChildren(db, child, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChildren(db, child, ownerId) {
|
||||||
|
const where = [`${child.owner.column} = ?`];
|
||||||
|
const params = [ownerId];
|
||||||
|
|
||||||
|
if (child.owner.kindColumn) {
|
||||||
|
where.push(`${child.owner.kindColumn} = ?`);
|
||||||
|
params.push(child.owner.kindValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ${child.table}
|
||||||
|
WHERE ${where.join(" AND ")}
|
||||||
|
ORDER BY ${child.order}`,
|
||||||
|
)
|
||||||
|
.all(...params);
|
||||||
|
|
||||||
|
for (const child2 of child.children ?? []) {
|
||||||
|
for (const row of rows) {
|
||||||
|
row[child2.key] = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ${child2.table}
|
||||||
|
WHERE ${child2.owner.column} = ?
|
||||||
|
ORDER BY ${child2.order}`,
|
||||||
|
)
|
||||||
|
.all(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Write ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function createRow(db, entity, payload) {
|
||||||
|
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (entity.idKind === "slug" && !SLUG.test(id)) {
|
||||||
|
throw new HttpError(422, "Validation failed", {
|
||||||
|
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = db
|
||||||
|
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
|
.get(id);
|
||||||
|
if (exists) {
|
||||||
|
throw new HttpError(422, "Validation failed", {
|
||||||
|
[entity.idColumn]: "Already taken.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { values, errors } = coerceRow(entity.columns, payload);
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
throw new HttpError(422, "Validation failed", errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = [entity.idColumn, ...Object.keys(values)];
|
||||||
|
|
||||||
|
wrapDbErrors(() =>
|
||||||
|
tx(db, () => {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ${entity.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})`,
|
||||||
|
).run(id, ...Object.values(values));
|
||||||
|
|
||||||
|
writeExtensions(db, entity, id, payload, values);
|
||||||
|
writeChildren(db, entity, id, payload, values);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return readRow(db, entity, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRow(db, entity, id, payload) {
|
||||||
|
const current = db
|
||||||
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
|
.get(id);
|
||||||
|
if (!current) throw new HttpError(404, "Not found.");
|
||||||
|
|
||||||
|
// Optimistic concurrency. The client echoes back the updated_at
|
||||||
|
// it loaded; anything else means someone saved in between.
|
||||||
|
if (entity.concurrency) {
|
||||||
|
const seen = payload?.[entity.concurrency];
|
||||||
|
if (!seen) {
|
||||||
|
throw new HttpError(400, `Missing ${entity.concurrency}.`);
|
||||||
|
}
|
||||||
|
if (seen !== current[entity.concurrency]) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
"Someone else saved this while you were editing. Reload to see their version.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { values, errors } = coerceRow(entity.columns, payload);
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
throw new HttpError(422, "Validation failed", errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unsent column keeps its stored value, so the gates below
|
||||||
|
// have to read the merged row, not just what came in.
|
||||||
|
const merged = { ...current, ...values };
|
||||||
|
|
||||||
|
wrapDbErrors(() =>
|
||||||
|
tx(db, () => {
|
||||||
|
const sets = Object.keys(values).map((name) => `${name} = ?`);
|
||||||
|
if (sets.length) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE ${entity.table} SET ${sets.join(", ")}
|
||||||
|
WHERE ${entity.idColumn} = ?`,
|
||||||
|
).run(...Object.values(values), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeExtensions(db, entity, id, payload, merged);
|
||||||
|
writeChildren(db, entity, id, payload, merged);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return readRow(db, entity, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRow(db, entity, id) {
|
||||||
|
const result = wrapDbErrors(() =>
|
||||||
|
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
||||||
|
);
|
||||||
|
if (result.changes === 0) throw new HttpError(404, "Not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Write helpers ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function writeExtensions(db, entity, id, payload, parentValues) {
|
||||||
|
for (const ext of entity.extensions ?? []) {
|
||||||
|
if (!applies(ext.when, parentValues)) {
|
||||||
|
// The gate closed — the kind changed away from this side
|
||||||
|
// table, so its row (and anything cascading off it) goes.
|
||||||
|
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
|
||||||
|
prefix: `${ext.key}.`,
|
||||||
|
});
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
throw new HttpError(422, "Validation failed", errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
|
||||||
|
const names = [ext.idColumn, ...Object.keys(values)];
|
||||||
|
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
||||||
|
|
||||||
|
// Upsert rather than delete-and-insert: deleting a regions row
|
||||||
|
// would cascade its region_areas away underneath us. With every
|
||||||
|
// optional column omitted there is nothing to set, so the
|
||||||
|
// conflict clause has to degrade to DO NOTHING or the SQL is
|
||||||
|
// syntactically invalid.
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ${ext.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})
|
||||||
|
ON CONFLICT(${ext.idColumn}) ${
|
||||||
|
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
||||||
|
}`,
|
||||||
|
).run(id, ...Object.values(values));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlankChildRow(child, raw) {
|
||||||
|
if (!raw || typeof raw !== "object") return true;
|
||||||
|
|
||||||
|
for (const column of child.columns) {
|
||||||
|
const value = raw[column.name];
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
// An unchecked box is the default state of a new row, not input.
|
||||||
|
if (
|
||||||
|
column.type === "bool" &&
|
||||||
|
(value === false || value === 0 || value === "0" || value === "false")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child2 of child.children ?? []) {
|
||||||
|
const nested = raw[child2.key];
|
||||||
|
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyedDbErrors(prefix, fn) {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) throw err;
|
||||||
|
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
|
||||||
|
if (notNull) {
|
||||||
|
throw new HttpError(422, "Validation failed", {
|
||||||
|
[`${prefix}${notNull[1]}`]: "Required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChildren(db, entity, id, payload, parentValues) {
|
||||||
|
for (const child of entity.children ?? []) {
|
||||||
|
if (!applies(child.when, parentValues)) {
|
||||||
|
deleteChildren(db, child, id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (payload[child.key] === undefined) continue; // not sent, not touched
|
||||||
|
|
||||||
|
deleteChildren(db, child, id);
|
||||||
|
|
||||||
|
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
|
||||||
|
|
||||||
|
// index is the row's place in what the client sent, so a
|
||||||
|
// validation error still points at the row the user is looking
|
||||||
|
// at. position counts only the rows that survive, so dropping a
|
||||||
|
// blank in the middle doesn't leave a gap in sort_order.
|
||||||
|
const rows = incoming
|
||||||
|
.map((raw, index) => ({ raw, index }))
|
||||||
|
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
|
||||||
|
|
||||||
|
rows.forEach(({ raw, index }, position) => {
|
||||||
|
const { values, errors } = coerceRow(child.columns, raw, {
|
||||||
|
prefix: `${child.key}.${index}.`,
|
||||||
|
});
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
throw new HttpError(422, "Validation failed", errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = [child.owner.column, ...Object.keys(values)];
|
||||||
|
const params = [id, ...Object.values(values)];
|
||||||
|
|
||||||
|
if (child.owner.kindColumn) {
|
||||||
|
names.push(child.owner.kindColumn);
|
||||||
|
params.push(child.owner.kindValue);
|
||||||
|
}
|
||||||
|
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
|
||||||
|
if (names.includes(column)) continue;
|
||||||
|
names.push(column);
|
||||||
|
params.push(parentValues[from] ?? id);
|
||||||
|
}
|
||||||
|
if (child.order === "sort_order" && !names.includes("sort_order")) {
|
||||||
|
names.push("sort_order");
|
||||||
|
params.push(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO ${child.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})`,
|
||||||
|
)
|
||||||
|
.run(...params),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const child2 of child.children ?? []) {
|
||||||
|
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
|
||||||
|
const nested = incomingNested
|
||||||
|
.map((rawItem, i) => ({ rawItem, i }))
|
||||||
|
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
|
||||||
|
|
||||||
|
nested.forEach(({ rawItem, i }, nestedPosition) => {
|
||||||
|
const item = coerceRow(child2.columns, rawItem, {
|
||||||
|
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
|
||||||
|
});
|
||||||
|
if (Object.keys(item.errors).length) {
|
||||||
|
throw new HttpError(422, "Validation failed", item.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
|
||||||
|
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
|
||||||
|
if (!itemNames.includes("sort_order")) {
|
||||||
|
itemNames.push("sort_order");
|
||||||
|
itemParams.push(nestedPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
|
||||||
|
VALUES (${itemNames.map(() => "?").join(", ")})`,
|
||||||
|
).run(...itemParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChildren(db, child, ownerId) {
|
||||||
|
if (child.owner.kindColumn) {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM ${child.table}
|
||||||
|
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
|
||||||
|
).run(ownerId, child.owner.kindValue);
|
||||||
|
} else {
|
||||||
|
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SQLite's constraint messages are accurate and unreadable. Turn
|
||||||
|
the ones that users actually cause into something actionable,
|
||||||
|
and name the column wherever the message carries it — a missing
|
||||||
|
field is the client's problem to fix, not a 500.
|
||||||
|
|
||||||
|
The descriptor and the schema agreeing (see admin-schema-sync.js)
|
||||||
|
should stop most of these arriving. This is the backstop for the
|
||||||
|
cases it can't see: partial indexes, triggers, CHECK constraints. */
|
||||||
|
function wrapDbErrors(fn) {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) throw err;
|
||||||
|
const message = String(err.message ?? "");
|
||||||
|
|
||||||
|
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
|
||||||
|
if (notNull) {
|
||||||
|
throw new HttpError(422, "Validation failed", {
|
||||||
|
[notNull[1]]: "Required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
|
||||||
|
if (unique) {
|
||||||
|
const columns = unique[1]
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim().split(".")[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
if (columns.length === 1) {
|
||||||
|
throw new HttpError(422, "Validation failed", {
|
||||||
|
[columns[0]]: "Already taken.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new HttpError(422, "That combination already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("FOREIGN KEY")) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
"Something references a row that doesn't exist, or is still referenced elsewhere.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = /CHECK constraint failed: (\w+)/.exec(message);
|
||||||
|
if (check) {
|
||||||
|
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
server/src/admin-schema-sync.js
Normal file
136
server/src/admin-schema-sync.js
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
DESCRIPTOR / SCHEMA RECONCILIATION
|
||||||
|
|
||||||
|
The CRUD engine trusts admin-schema.js about which fields are
|
||||||
|
required. The database is the one that actually enforces it. When
|
||||||
|
those two disagree the gap shows up as a 500 on somebody's save,
|
||||||
|
which is how organizations.country was found.
|
||||||
|
|
||||||
|
Run this once at boot. For every table a descriptor touches it
|
||||||
|
compares the declared columns against PRAGMA table_info and:
|
||||||
|
|
||||||
|
- marks a descriptor column required when the table says
|
||||||
|
NOT NULL with no DEFAULT, so validation catches it as a named
|
||||||
|
field instead of SQLite catching it as a 500;
|
||||||
|
- reports a descriptor column the table doesn't have;
|
||||||
|
- reports a NOT NULL, no-DEFAULT column that no descriptor
|
||||||
|
column and no engine-supplied column covers — nothing can
|
||||||
|
ever set it, so every insert through the admin will fail.
|
||||||
|
|
||||||
|
The first is a silent repair. The other two are deployment bugs,
|
||||||
|
so with { strict: true } they stop the service starting rather
|
||||||
|
than waiting to surface one row at a time.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function tableMeta(db, table) {
|
||||||
|
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
const meta = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
meta.set(row.name, {
|
||||||
|
// A NOT NULL column with no default and no engine to fill it
|
||||||
|
// has to arrive in the payload or the insert dies.
|
||||||
|
mustSupply: row.notnull === 1 && row.dflt_value === null && row.pk === 0,
|
||||||
|
notNullable: row.notnull === 1 && row.pk === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every table the descriptor writes to, with the columns it
|
||||||
|
declares and the ones the engine fills in on its own. */
|
||||||
|
function collectGroups(entity) {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
table: entity.table,
|
||||||
|
columns: entity.columns ?? [],
|
||||||
|
engineSupplied: [entity.idColumn],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ext of entity.extensions ?? []) {
|
||||||
|
groups.push({
|
||||||
|
table: ext.table,
|
||||||
|
columns: ext.columns ?? [],
|
||||||
|
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const walk = (child) => {
|
||||||
|
groups.push({
|
||||||
|
table: child.table,
|
||||||
|
columns: child.columns ?? [],
|
||||||
|
engineSupplied: [
|
||||||
|
child.owner.column,
|
||||||
|
child.owner.kindColumn,
|
||||||
|
"sort_order",
|
||||||
|
...Object.keys(child.owner.inherit ?? {}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
for (const nested of child.children ?? []) walk(nested);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of entity.children ?? []) walk(child);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncDescriptorsWithSchema(db, entities, { strict = true } = {}) {
|
||||||
|
const list = Array.isArray(entities) ? entities.map((e) => [e.name ?? e.table, e]) : Object.entries(entities);
|
||||||
|
|
||||||
|
const inferred = [];
|
||||||
|
const problems = [];
|
||||||
|
|
||||||
|
for (const [name, entity] of list) {
|
||||||
|
for (const group of collectGroups(entity)) {
|
||||||
|
const meta = tableMeta(db, group.table);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
problems.push(`${name}: table "${group.table}" does not exist.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declared = new Set(group.columns.map((column) => column.name));
|
||||||
|
|
||||||
|
for (const column of group.columns) {
|
||||||
|
const info = meta.get(column.name);
|
||||||
|
if (!info) {
|
||||||
|
problems.push(
|
||||||
|
`${name}: ${group.table}.${column.name} is in the descriptor but not in the table.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.mustSupply && !column.required) {
|
||||||
|
column.required = true;
|
||||||
|
inferred.push(`${name}: ${group.table}.${column.name}`);
|
||||||
|
}
|
||||||
|
if (info.notNullable) column.notNullable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [columnName, info] of meta) {
|
||||||
|
if (!info.mustSupply) continue;
|
||||||
|
if (declared.has(columnName)) continue;
|
||||||
|
if (group.engineSupplied.includes(columnName)) continue;
|
||||||
|
problems.push(
|
||||||
|
`${name}: ${group.table}.${columnName} is NOT NULL with no default, ` +
|
||||||
|
`but no descriptor column covers it — every insert will fail.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inferred.length) {
|
||||||
|
console.warn(
|
||||||
|
`[admin-schema] marked required from the schema (add required: true to the descriptor):\n ${inferred.join("\n ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
const report = `[admin-schema] descriptor does not match the database:\n ${problems.join("\n ")}`;
|
||||||
|
if (strict) throw new Error(report);
|
||||||
|
console.error(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inferred, problems };
|
||||||
|
}
|
||||||
518
server/src/admin-schema.js
Normal file
518
server/src/admin-schema.js
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN ENTITY DESCRIPTORS
|
||||||
|
|
||||||
|
One object per editable entity. Everything the CRUD handlers do
|
||||||
|
— validation, SQL, nesting — is read from here, so adding a
|
||||||
|
table later is a descriptor rather than another set of
|
||||||
|
hand-written statements to keep in step with the schema.
|
||||||
|
|
||||||
|
Anatomy of a descriptor:
|
||||||
|
|
||||||
|
columns writable columns of the parent row
|
||||||
|
extensions 1:1 side tables, optionally gated on a column
|
||||||
|
value (organizations.kind decides whether a
|
||||||
|
regions or chapters row should exist)
|
||||||
|
children ordered collections, replaced wholesale on save
|
||||||
|
|
||||||
|
Replacing children wholesale is only safe because nothing has a
|
||||||
|
foreign key INTO these tables. That is the dividing line, and
|
||||||
|
it is why teams and awards are entities of their own rather
|
||||||
|
than repeaters on the organization form: affiliations.team_id
|
||||||
|
and person_awards.award_id point at them, so a delete-and-
|
||||||
|
reinsert save would abort the moment either had a single
|
||||||
|
dependent row.
|
||||||
|
|
||||||
|
Parent ids are immutable. Polymorphic children reference their
|
||||||
|
owner by free-text owner_id, so renaming a slug in place would
|
||||||
|
silently orphan every link and content block attached to it.
|
||||||
|
|
||||||
|
affiliations is edited from both ends — a person's roles, and a
|
||||||
|
team's members. Each side deletes and reinserts only its own
|
||||||
|
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
|
||||||
|
every column of the row, so a save from one side round-trips
|
||||||
|
what the other side owns rather than blanking it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Column helpers ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
|
||||||
|
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
|
||||||
|
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
|
||||||
|
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
|
||||||
|
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
|
||||||
|
const enumeration = (name, values, opts = {}) => ({
|
||||||
|
name,
|
||||||
|
type: "enum",
|
||||||
|
values,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Place columns shared by organizations and events, in schema order. */
|
||||||
|
const placeColumns = [
|
||||||
|
text("venue"),
|
||||||
|
text("address"),
|
||||||
|
text("locality"),
|
||||||
|
text("state_code"),
|
||||||
|
text("country"),
|
||||||
|
text("location_label"),
|
||||||
|
real("latitude"),
|
||||||
|
real("longitude"),
|
||||||
|
bool("is_online"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The affiliation's own fields, minus whichever end owns the row.
|
||||||
|
Both editors write the same shape so neither loses the other's
|
||||||
|
values on save. */
|
||||||
|
const affiliationRole = [
|
||||||
|
text("title"),
|
||||||
|
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
bool("is_owner"),
|
||||||
|
date("started_on"),
|
||||||
|
date("ended_on"),
|
||||||
|
bool("is_public"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The two polymorphic collections, parameterised by owner_kind. */
|
||||||
|
const linksChild = (ownerKind) => ({
|
||||||
|
key: "links",
|
||||||
|
table: "links",
|
||||||
|
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
enumeration("kind", ["action", "social", "website", "email"], {
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
text("platform"),
|
||||||
|
text("label", { required: true }),
|
||||||
|
text("url", { required: true }),
|
||||||
|
bool("is_primary"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocksChild = (ownerKind) => ({
|
||||||
|
key: "content_blocks",
|
||||||
|
table: "content_blocks",
|
||||||
|
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
enumeration("slot", ["card", "body"], { required: true }),
|
||||||
|
enumeration(
|
||||||
|
"type",
|
||||||
|
[
|
||||||
|
"heading",
|
||||||
|
"subheading",
|
||||||
|
"paragraph",
|
||||||
|
"list",
|
||||||
|
"links",
|
||||||
|
"quote",
|
||||||
|
"image",
|
||||||
|
"divider",
|
||||||
|
],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
text("text"),
|
||||||
|
text("media"),
|
||||||
|
text("href"),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
table: "content_block_items",
|
||||||
|
owner: { column: "block_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("text", { required: true }),
|
||||||
|
text("detail"),
|
||||||
|
text("url"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const organizations = {
|
||||||
|
key: "organizations",
|
||||||
|
table: "organizations",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"short_name",
|
||||||
|
"locality",
|
||||||
|
"state_code",
|
||||||
|
"is_published",
|
||||||
|
"sort_order",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["kind", "is_published"],
|
||||||
|
search: ["name", "id", "locality"],
|
||||||
|
order: "kind, sort_order, name",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
enumeration("kind", ["national", "region", "chapter", "partner"], {
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
text("name", { required: true }),
|
||||||
|
text("short_name"),
|
||||||
|
text("tagline"),
|
||||||
|
text("color"),
|
||||||
|
text("logo"),
|
||||||
|
...placeColumns,
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
key: "region",
|
||||||
|
table: "regions",
|
||||||
|
idColumn: "id",
|
||||||
|
when: { column: "kind", value: "region" },
|
||||||
|
columns: [
|
||||||
|
enumeration("scope", ["domestic", "international", "virtual"], {
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
text("map_note"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chapter",
|
||||||
|
table: "chapters",
|
||||||
|
idColumn: "id",
|
||||||
|
when: { column: "kind", value: "chapter" },
|
||||||
|
columns: [text("region_id"), text("meets"), text("started")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
linksChild("organization"),
|
||||||
|
blocksChild("organization"),
|
||||||
|
{
|
||||||
|
key: "region_areas",
|
||||||
|
table: "region_areas",
|
||||||
|
owner: { column: "region_id" },
|
||||||
|
when: { column: "kind", value: "region" },
|
||||||
|
order: "area_code",
|
||||||
|
columns: [
|
||||||
|
text("area_code", { required: true }),
|
||||||
|
real("share"),
|
||||||
|
enumeration("edge", ["top", "bottom"]),
|
||||||
|
text("note"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Events ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const events = {
|
||||||
|
key: "events",
|
||||||
|
table: "events",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"section_id",
|
||||||
|
"host_org_id",
|
||||||
|
"date_label",
|
||||||
|
"starts_on",
|
||||||
|
"status",
|
||||||
|
"is_published",
|
||||||
|
"sort_order",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["section_id", "status", "is_published", "host_org_id"],
|
||||||
|
search: ["title", "id", "theme"],
|
||||||
|
order: "sort_order, starts_on DESC, title",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("section_id", { required: true }),
|
||||||
|
text("host_org_id"),
|
||||||
|
text("title", { required: true }),
|
||||||
|
text("theme"),
|
||||||
|
text("tagline"),
|
||||||
|
date("starts_on"),
|
||||||
|
date("ends_on"),
|
||||||
|
text("date_label"),
|
||||||
|
enumeration("status", ["upcoming", "past", "cancelled"]),
|
||||||
|
...placeColumns,
|
||||||
|
text("org_logo"),
|
||||||
|
text("event_logo"),
|
||||||
|
text("color"),
|
||||||
|
text("gradient"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
linksChild("event"),
|
||||||
|
blocksChild("event"),
|
||||||
|
{
|
||||||
|
key: "event_people",
|
||||||
|
table: "event_people",
|
||||||
|
owner: { column: "event_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("person_id", { required: true }),
|
||||||
|
enumeration(
|
||||||
|
"role",
|
||||||
|
[
|
||||||
|
"speaker",
|
||||||
|
"leader",
|
||||||
|
"facilitator",
|
||||||
|
"host",
|
||||||
|
"musician",
|
||||||
|
"volunteer",
|
||||||
|
"attendee",
|
||||||
|
],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
text("title"),
|
||||||
|
bool("is_public"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── People ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const people = {
|
||||||
|
key: "people",
|
||||||
|
table: "people",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"sort_name",
|
||||||
|
"tagline",
|
||||||
|
"locality",
|
||||||
|
"is_published",
|
||||||
|
"sort_order",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["is_published"],
|
||||||
|
search: ["display_name", "sort_name", "id"],
|
||||||
|
order: "sort_order, sort_name, display_name",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("display_name", { required: true }),
|
||||||
|
text("sort_name"),
|
||||||
|
text("pronouns"),
|
||||||
|
text("tagline"),
|
||||||
|
text("photo"),
|
||||||
|
text("bio"),
|
||||||
|
text("primary_org_id"),
|
||||||
|
text("public_email"),
|
||||||
|
text("public_phone"),
|
||||||
|
text("locality"),
|
||||||
|
text("state_code"),
|
||||||
|
text("country"),
|
||||||
|
text("location_label"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
key: "private",
|
||||||
|
table: "person_private",
|
||||||
|
idColumn: "person_id",
|
||||||
|
touch: true, // has its own updated_at with no trigger behind it
|
||||||
|
columns: [
|
||||||
|
date("birth_date"),
|
||||||
|
text("private_email"),
|
||||||
|
text("private_phone"),
|
||||||
|
text("address"),
|
||||||
|
text("notes"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
linksChild("person"),
|
||||||
|
blocksChild("person"),
|
||||||
|
{
|
||||||
|
key: "affiliations",
|
||||||
|
table: "affiliations",
|
||||||
|
owner: { column: "person_id" },
|
||||||
|
|
||||||
|
// sort_order is where this person sits on that team, so it
|
||||||
|
// belongs to the team's editor. reindex: false stops this
|
||||||
|
// form renumbering by row position; declaring the column
|
||||||
|
// keeps the team's value intact across a save here.
|
||||||
|
reindex: false,
|
||||||
|
order: "org_id, team_id, sort_order",
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("org_id", { required: true }),
|
||||||
|
text("team_id"),
|
||||||
|
...affiliationRole,
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "person_awards",
|
||||||
|
table: "person_awards",
|
||||||
|
owner: { column: "person_id" },
|
||||||
|
order: "awarded_on",
|
||||||
|
columns: [
|
||||||
|
text("award_id", { required: true }),
|
||||||
|
text("event_id"),
|
||||||
|
date("awarded_on"),
|
||||||
|
text("citation"),
|
||||||
|
bool("is_public"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Teams ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// A team belongs to exactly one organization, and affiliations
|
||||||
|
// point at the pair (team_id, org_id) rather than the team alone.
|
||||||
|
// Two consequences the form has to live with:
|
||||||
|
//
|
||||||
|
// · org_id cannot be changed once anyone is filed under the
|
||||||
|
// team. The composite foreign key has no ON UPDATE CASCADE, so
|
||||||
|
// SQLite aborts the UPDATE. That surfaces as a constraint
|
||||||
|
// error, which is the correct answer — reassign the members
|
||||||
|
// first.
|
||||||
|
//
|
||||||
|
// · deleting a team with members fails the same way, rather than
|
||||||
|
// quietly detaching them. Emptying the members list first is
|
||||||
|
// now something the form can do.
|
||||||
|
//
|
||||||
|
// No concurrency column: teams have no updated_at. Adding one
|
||||||
|
// means rebuilding a STRICT table for a row that one person edits
|
||||||
|
// at a time, which is not a trade worth making yet.
|
||||||
|
const teams = {
|
||||||
|
key: "teams",
|
||||||
|
table: "teams",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
|
||||||
|
filters: ["org_id", "is_published"],
|
||||||
|
search: ["name", "id", "tagline"],
|
||||||
|
order: "org_id, sort_order, name",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("org_id", { required: true }),
|
||||||
|
text("name", { required: true }),
|
||||||
|
text("tagline"),
|
||||||
|
text("color"),
|
||||||
|
text("logo"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
// 'team' is already a valid owner_kind in both polymorphic
|
||||||
|
// tables, and teams_cleanup drops the rows on delete, so a team
|
||||||
|
// page comes free.
|
||||||
|
linksChild("team"),
|
||||||
|
blocksChild("team"),
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
table: "affiliations",
|
||||||
|
|
||||||
|
// org_id is inherited from the team rather than asked for:
|
||||||
|
// the composite foreign key (team_id, org_id) means a member
|
||||||
|
// of this team can only belong to this team's organization,
|
||||||
|
// so a second dropdown could only ever be wrong.
|
||||||
|
owner: { column: "team_id", inherit: { org_id: "org_id" } },
|
||||||
|
|
||||||
|
// Row position is the order they appear on the public site.
|
||||||
|
// This is the only editor that writes it.
|
||||||
|
order: "sort_order",
|
||||||
|
|
||||||
|
columns: [text("person_id", { required: true }), ...affiliationRole],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Awards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// org_id is who gives the award, added in 004. Nullable, because
|
||||||
|
// an award can predate any decision about which organization owns
|
||||||
|
// it, and because person_awards rows must survive the awarding
|
||||||
|
// org being deleted.
|
||||||
|
//
|
||||||
|
// No children: 'award' is not in the owner_kind CHECK on
|
||||||
|
// content_blocks or links. If awards ever need a page of their
|
||||||
|
// own, that CHECK is a table rebuild, so decide before adding one
|
||||||
|
// rather than after.
|
||||||
|
const awards = {
|
||||||
|
key: "awards",
|
||||||
|
table: "awards",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: ["id", "org_id", "name", "description", "sort_order"],
|
||||||
|
filters: ["org_id"],
|
||||||
|
search: ["name", "id", "description"],
|
||||||
|
order: "org_id, sort_order, name",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("org_id"),
|
||||||
|
text("name", { required: true }),
|
||||||
|
text("description"),
|
||||||
|
text("logo"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITIES = { organizations, events, people, teams, awards };
|
||||||
|
|
||||||
|
/* ── Options for the form's select inputs ────────────────────── */
|
||||||
|
|
||||||
|
export const OPTION_QUERIES = {
|
||||||
|
organizations:
|
||||||
|
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
|
||||||
|
regions:
|
||||||
|
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
|
||||||
|
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
|
||||||
|
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
|
||||||
|
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
|
||||||
|
|
||||||
|
// org_id rides along so the affiliation row can filter the list
|
||||||
|
// down to teams of the organization it already names.
|
||||||
|
teams:
|
||||||
|
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
|
||||||
|
|
||||||
|
// The awarding organization is folded into the label instead,
|
||||||
|
// because a person can receive an award from any organization —
|
||||||
|
// there is nothing to filter on, only something to disambiguate
|
||||||
|
// when two orgs name an award the same thing.
|
||||||
|
awards: `
|
||||||
|
SELECT a.id,
|
||||||
|
CASE WHEN o.name IS NULL THEN a.name
|
||||||
|
ELSE a.name || ' — ' || o.name END AS label,
|
||||||
|
a.org_id
|
||||||
|
FROM awards a
|
||||||
|
LEFT JOIN organizations o ON o.id = a.org_id
|
||||||
|
ORDER BY a.sort_order, a.name`,
|
||||||
|
};
|
||||||
226
server/src/auth.js
Normal file
226
server/src/auth.js
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
AUTH
|
||||||
|
|
||||||
|
Everything that decides who someone is. Routes decide what they
|
||||||
|
may do.
|
||||||
|
|
||||||
|
Password storage is scrypt from node:crypto — no native module,
|
||||||
|
nothing to compile, nothing for pnpm to get wrong. The stored
|
||||||
|
string carries its own parameters, so raising the cost later
|
||||||
|
doesn't invalidate existing hashes.
|
||||||
|
|
||||||
|
Sessions are opaque random tokens. The browser holds the token
|
||||||
|
in an HttpOnly cookie; the database holds only its SHA-256. A
|
||||||
|
leaked backup therefore contains no usable session, and signing
|
||||||
|
someone out is a DELETE rather than a wait for expiry.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||||
|
|
||||||
|
export const COOKIE_NAME = "ngu_session";
|
||||||
|
|
||||||
|
const SESSION_DAYS = 30;
|
||||||
|
// Re-issue the expiry when a session is used with less than this
|
||||||
|
// left, so an active person is never signed out mid-task.
|
||||||
|
const REFRESH_WITHIN_DAYS = 7;
|
||||||
|
|
||||||
|
// Off only for plain-http local work. Production is behind TLS.
|
||||||
|
const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false";
|
||||||
|
|
||||||
|
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
|
||||||
|
|
||||||
|
/* ── Passwords ─────────────────────────────────────────────────
|
||||||
|
Format: scrypt$N$r$p$saltHex$keyHex
|
||||||
|
N=16384 r=8 p=1 is the standard interactive cost, roughly
|
||||||
|
100ms per hash on this class of hardware.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 };
|
||||||
|
|
||||||
|
export function hashPassword(password) {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, {
|
||||||
|
N: SCRYPT.N,
|
||||||
|
r: SCRYPT.r,
|
||||||
|
p: SCRYPT.p,
|
||||||
|
// scrypt's default memory cap is below what N=16384 needs.
|
||||||
|
maxmem: 256 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
"scrypt",
|
||||||
|
SCRYPT.N,
|
||||||
|
SCRYPT.r,
|
||||||
|
SCRYPT.p,
|
||||||
|
salt.toString("hex"),
|
||||||
|
key.toString("hex"),
|
||||||
|
].join("$");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password, stored) {
|
||||||
|
if (typeof stored !== "string") return false;
|
||||||
|
|
||||||
|
const [scheme, N, r, p, saltHex, keyHex] = stored.split("$");
|
||||||
|
if (scheme !== "scrypt") return false;
|
||||||
|
|
||||||
|
let candidate;
|
||||||
|
try {
|
||||||
|
candidate = scryptSync(
|
||||||
|
password.normalize("NFKC"),
|
||||||
|
Buffer.from(saltHex, "hex"),
|
||||||
|
Buffer.from(keyHex, "hex").length,
|
||||||
|
{ N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = Buffer.from(keyHex, "hex");
|
||||||
|
if (candidate.length !== expected.length) return false;
|
||||||
|
return timingSafeEqual(candidate, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hashing helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
|
export function hashIp(ip) {
|
||||||
|
if (!ip) return null;
|
||||||
|
return sha256(`${IP_SALT}:${ip}`).slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientIp(c) {
|
||||||
|
// Trustworthy only because this service binds to 127.0.0.1 and
|
||||||
|
// nginx is the only thing that can reach it.
|
||||||
|
return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sessions ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function createSession(db, c, userId) {
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash)
|
||||||
|
VALUES (?, ?, datetime('now', ?), ?, ?)`,
|
||||||
|
).run(
|
||||||
|
sha256(token),
|
||||||
|
userId,
|
||||||
|
`+${SESSION_DAYS} days`,
|
||||||
|
c.req.header("user-agent")?.slice(0, 500) ?? null,
|
||||||
|
hashIp(clientIp(c)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCookie(c, COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: SECURE_COOKIE,
|
||||||
|
// Lax, not Strict: Strict would drop the cookie when someone
|
||||||
|
// follows a link into /admin from elsewhere, which reads as
|
||||||
|
// being randomly signed out. Lax still blocks the cross-site
|
||||||
|
// POST that CSRF depends on.
|
||||||
|
sameSite: "Lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(db, c) {
|
||||||
|
const token = getCookie(c, COOKIE_NAME);
|
||||||
|
if (token) {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token));
|
||||||
|
}
|
||||||
|
deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyAllSessionsFor(db, userId) {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Returns the signed-in user, or null. Also slides the expiry
|
||||||
|
forward when the session is getting old. */
|
||||||
|
export function currentUser(db, c) {
|
||||||
|
const token = getCookie(c, COOKIE_NAME);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const tokenHash = sha256(token);
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id AS session_id,
|
||||||
|
s.expires_at AS expires_at,
|
||||||
|
u.id, u.email, u.name, u.role
|
||||||
|
FROM sessions s
|
||||||
|
JOIN admin_users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ?
|
||||||
|
AND s.expires_at > datetime('now')
|
||||||
|
AND u.is_active = 1`,
|
||||||
|
)
|
||||||
|
.get(tokenHash);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const refreshDue = db
|
||||||
|
.prepare("SELECT ? < datetime('now', ?) AS due")
|
||||||
|
.get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`);
|
||||||
|
|
||||||
|
if (refreshDue?.due) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions
|
||||||
|
SET expires_at = datetime('now', ?), last_seen_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(`+${SESSION_DAYS} days`, row.session_id);
|
||||||
|
|
||||||
|
setCookie(c, COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: SECURE_COOKIE,
|
||||||
|
sameSite: "Lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: row.id, email: row.email, name: row.name, role: row.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Middleware ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export async function requireAuth(c, next) {
|
||||||
|
const user = currentUser(c.get("db"), c);
|
||||||
|
if (!user) return c.json({ error: "Not signed in." }, 401);
|
||||||
|
c.set("user", user);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(...roles) {
|
||||||
|
return async (c, next) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
if (!user || !roles.includes(user.role)) {
|
||||||
|
return c.json({ error: "Not allowed." }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Housekeeping ──────────────────────────────────────────────
|
||||||
|
Expired rows are already ignored by every query; this just
|
||||||
|
stops the table growing without bound.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) {
|
||||||
|
const sweep = () => {
|
||||||
|
try {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("session sweep failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sweep();
|
||||||
|
setInterval(sweep, everyMs).unref();
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,14 @@ import { logger } from "hono/logger";
|
||||||
import { openDatabase, migrate } from "./db.js";
|
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 feedback from "./routes/feedback.js";
|
import feedback from "./routes/feedback.js";
|
||||||
|
import auth from "./routes/auth.js";
|
||||||
|
import admin from "./routes/admin.js";
|
||||||
|
import adminEntities from "./routes/admin-entities.js";
|
||||||
|
import { startSessionSweeper } from "./auth.js";
|
||||||
|
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
|
||||||
|
import { ENTITIES } from "./admin-schema.js";
|
||||||
|
|
||||||
const HOST = process.env.HOST ?? "127.0.0.1";
|
const HOST = process.env.HOST ?? "127.0.0.1";
|
||||||
const PORT = Number(process.env.PORT ?? 3001);
|
const PORT = Number(process.env.PORT ?? 3001);
|
||||||
|
|
@ -25,6 +32,7 @@ const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
|
||||||
|
|
||||||
const db = await openDatabase(DB_PATH);
|
const db = await openDatabase(DB_PATH);
|
||||||
const version = migrate(db);
|
const version = migrate(db);
|
||||||
|
syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES);
|
||||||
|
|
||||||
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
|
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
|
||||||
|
|
||||||
|
|
@ -44,11 +52,19 @@ app.get("/api/health", (c) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
app.route("/api", content);
|
app.route("/api", content);
|
||||||
|
app.route("/api", people);
|
||||||
|
|
||||||
// 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.route("/api/auth", auth);
|
||||||
|
app.route("/api/admin", admin);
|
||||||
|
app.route("/api/admin", adminEntities);
|
||||||
|
|
||||||
|
startSessionSweeper(db);
|
||||||
|
|
||||||
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
|
|
|
||||||
55
server/src/migrations/003_auth.sql
Normal file
55
server/src/migrations/003_auth.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 003 AUTHENTICATION
|
||||||
|
--
|
||||||
|
-- Two tables: who may sign in, and who currently is signed in.
|
||||||
|
--
|
||||||
|
-- There is no self-signup and no registration endpoint. Accounts
|
||||||
|
-- are created from the CLI, on the box, by someone with shell
|
||||||
|
-- access. For a handful of staff that's the right trade: no
|
||||||
|
-- invite flow, no email delivery, no password-reset surface for
|
||||||
|
-- anyone to attack.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE admin_users (
|
||||||
|
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,
|
||||||
|
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin'
|
||||||
|
CHECK (role IN ('admin', 'viewer')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
last_login_at TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
|
||||||
|
-- One row per active login. The cookie holds a random token; this
|
||||||
|
-- table holds only its SHA-256, so a database leak doesn't hand
|
||||||
|
-- anyone a working session.
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_hash TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX sessions_user_idx ON sessions (user_id);
|
||||||
|
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);
|
||||||
7
server/src/migrations/004_award_org.sql
Normal file
7
server/src/migrations/004_award_org.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 004_award_org.sql
|
||||||
|
-- Who gave the award. SET NULL rather than CASCADE: retiring a
|
||||||
|
-- partner org shouldn't erase an award people have received.
|
||||||
|
ALTER TABLE awards
|
||||||
|
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);
|
||||||
28
server/src/migrations/005_person_bio.sql
Normal file
28
server/src/migrations/005_person_bio.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- Person bio and primary organization
|
||||||
|
--
|
||||||
|
-- bio is one run of prose, not orderable mixed content, so it does
|
||||||
|
-- not belong in content_blocks — whose owner_kind CHECK would need
|
||||||
|
-- a full table rebuild to accept 'person' anyway. Paragraphs are
|
||||||
|
-- blank-line separated and split at render time.
|
||||||
|
--
|
||||||
|
-- primary_org_id is nullable on purpose: plenty of people have no
|
||||||
|
-- home organization worth printing, and ON DELETE SET NULL means
|
||||||
|
-- deleting an org blanks the reference rather than blocking the
|
||||||
|
-- delete or leaving a dangling id behind.
|
||||||
|
--
|
||||||
|
-- Check the current version before renumbering this file:
|
||||||
|
-- PRAGMA user_version;
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE people ADD COLUMN bio TEXT;
|
||||||
|
|
||||||
|
-- SQLite requires an added REFERENCES column to default to NULL,
|
||||||
|
-- which is what we want regardless.
|
||||||
|
ALTER TABLE people ADD COLUMN primary_org_id TEXT
|
||||||
|
REFERENCES organizations (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS people_primary_org
|
||||||
|
ON people (primary_org_id);
|
||||||
|
|
||||||
|
PRAGMA user_version = 0; -- ← set to this migration's number
|
||||||
45
server/src/migrations/006_leadership_view.sql
Normal file
45
server/src/migrations/006_leadership_view.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- v_org_leadership: add bio and primary organization
|
||||||
|
--
|
||||||
|
-- The view already carries the rules for who counts as current and
|
||||||
|
-- public. Adding the two columns the people tiles need keeps those
|
||||||
|
-- rules in one place instead of being restated by each route.
|
||||||
|
--
|
||||||
|
-- Additive only — attachLeadership does SELECT * and shapeLeader
|
||||||
|
-- picks fields by name, so existing callers are unaffected.
|
||||||
|
--
|
||||||
|
-- PRAGMA user_version; -- check before renumbering this file
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_org_leadership;
|
||||||
|
|
||||||
|
CREATE VIEW v_org_leadership AS
|
||||||
|
SELECT
|
||||||
|
a.org_id,
|
||||||
|
a.team_id,
|
||||||
|
t.name AS team_name,
|
||||||
|
t.sort_order AS team_sort_order,
|
||||||
|
a.person_id,
|
||||||
|
a.title,
|
||||||
|
a.role,
|
||||||
|
a.is_owner,
|
||||||
|
a.sort_order,
|
||||||
|
p.display_name,
|
||||||
|
p.sort_name,
|
||||||
|
p.pronouns,
|
||||||
|
p.tagline,
|
||||||
|
p.photo,
|
||||||
|
p.public_email,
|
||||||
|
p.location_label,
|
||||||
|
p.bio,
|
||||||
|
o.id AS primary_org_id,
|
||||||
|
o.name AS primary_org_name
|
||||||
|
FROM affiliations a
|
||||||
|
JOIN people p ON p.id = a.person_id AND p.is_published = 1
|
||||||
|
LEFT JOIN teams t ON t.id = a.team_id
|
||||||
|
LEFT JOIN organizations o ON o.id = p.primary_org_id
|
||||||
|
WHERE a.is_public = 1
|
||||||
|
AND a.ended_on IS NULL
|
||||||
|
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
|
||||||
|
|
||||||
|
PRAGMA user_version = 0; -- ← set to this migration's number
|
||||||
111
server/src/routes/admin-entities.js
Normal file
111
server/src/routes/admin-entities.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN ENTITY ROUTES
|
||||||
|
|
||||||
|
GET /api/admin/options
|
||||||
|
GET /api/admin/:entity list, filtered and searched
|
||||||
|
GET /api/admin/:entity/:id row with side tables and children
|
||||||
|
POST /api/admin/:entity create
|
||||||
|
PATCH /api/admin/:entity/:id replace the row and its children
|
||||||
|
DELETE /api/admin/:entity/:id
|
||||||
|
|
||||||
|
Mounted alongside the existing admin router, which keeps the
|
||||||
|
feedback routes. Both sit under the same requireAuth.
|
||||||
|
|
||||||
|
:entity is matched against the descriptor map, never
|
||||||
|
interpolated into SQL from the URL — an unknown name is a 404
|
||||||
|
before anything touches the database.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { requireAuth, requireRole } from "../auth.js";
|
||||||
|
import { ENTITIES, OPTION_QUERIES } from "../admin-schema.js";
|
||||||
|
import {
|
||||||
|
HttpError,
|
||||||
|
listRows,
|
||||||
|
readRow,
|
||||||
|
createRow,
|
||||||
|
updateRow,
|
||||||
|
deleteRow,
|
||||||
|
} from "../admin-crud.js";
|
||||||
|
|
||||||
|
const entities = new Hono();
|
||||||
|
|
||||||
|
entities.use("*", requireAuth);
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
function entityOr404(c) {
|
||||||
|
const entity = ENTITIES[c.req.param("entity")];
|
||||||
|
if (!entity) throw new HttpError(404, "Unknown entity.");
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything the select inputs need, in one request. Small enough
|
||||||
|
that paging it would cost more than it saves. */
|
||||||
|
entities.get("/options", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const options = {};
|
||||||
|
for (const [key, sql] of Object.entries(OPTION_QUERIES)) {
|
||||||
|
options[key] = db.prepare(sql).all();
|
||||||
|
}
|
||||||
|
return c.json({ options }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.get("/:entity", (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
const { rows, total } = listRows(c.get("db"), entity, c.req.query());
|
||||||
|
return c.json({ rows, total }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.get("/:entity/:id", (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.post("/:entity", requireRole("admin"), async (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
const row = createRow(c.get("db"), entity, await json(c));
|
||||||
|
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
|
||||||
|
return c.json({ row }, 201, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.patch("/:entity/:id", requireRole("admin"), async (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const row = updateRow(c.get("db"), entity, id, await json(c));
|
||||||
|
console.log(`${entity.key} ${id} updated by ${c.get("user").email}`);
|
||||||
|
return c.json({ row }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.delete("/:entity/:id", requireRole("admin"), (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
deleteRow(c.get("db"), entity, id);
|
||||||
|
console.log(`${entity.key} ${id} deleted by ${c.get("user").email}`);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function json(c) {
|
||||||
|
try {
|
||||||
|
return await c.req.json();
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, "Expected a JSON body.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Turn HttpError into a response here rather than letting the
|
||||||
|
app-level handler flatten everything to a 500. */
|
||||||
|
entities.onError((err, c) => {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return c.json(
|
||||||
|
err.fields ? { error: err.message, fields: err.fields } : { error: err.message },
|
||||||
|
err.status,
|
||||||
|
NO_STORE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
return c.json({ error: "Something went wrong." }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default entities;
|
||||||
172
server/src/routes/admin.js
Normal file
172
server/src/routes/admin.js
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN ROUTES
|
||||||
|
|
||||||
|
Everything under /api/admin requires a session. Writes require
|
||||||
|
the 'admin' role; a 'viewer' can read and nothing else.
|
||||||
|
|
||||||
|
No response from here is cacheable, and none of it should ever
|
||||||
|
sit in a proxy.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { requireAuth, requireRole } from "../auth.js";
|
||||||
|
|
||||||
|
const admin = new Hono();
|
||||||
|
|
||||||
|
admin.use("*", requireAuth);
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
const NOTE_LIMIT = 2000;
|
||||||
|
|
||||||
|
/* ── GET /api/admin/feedback ─────────────────────────────────────
|
||||||
|
?status=new one of STATUSES, or omitted for all
|
||||||
|
?type=broken feedback_type
|
||||||
|
?q=retreat substring of the message
|
||||||
|
?before=41 cursor: rows with a lower id than this
|
||||||
|
?limit=50
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
admin.get("/feedback", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const { status, type, q, before, limit } = c.req.query();
|
||||||
|
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (STATUSES.includes(status)) {
|
||||||
|
where.push("status = ?");
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
where.push("feedback_type = ?");
|
||||||
|
params.push(type.slice(0, 40));
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push("message LIKE ?");
|
||||||
|
params.push(`%${q.slice(0, 100)}%`);
|
||||||
|
}
|
||||||
|
if (before && Number.isInteger(Number(before))) {
|
||||||
|
where.push("id < ?");
|
||||||
|
params.push(Number(before));
|
||||||
|
}
|
||||||
|
|
||||||
|
const take = Math.min(Number(limit) || 50, MAX_LIMIT);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, created_at, feedback_type, message, name, email,
|
||||||
|
page_path, section_id, status, admin_note
|
||||||
|
FROM feedback
|
||||||
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...params, take + 1); // one extra to detect a next page
|
||||||
|
|
||||||
|
const hasMore = rows.length > take;
|
||||||
|
const page = hasMore ? rows.slice(0, take) : rows;
|
||||||
|
|
||||||
|
// Counts are unfiltered on purpose: the tabs should show what's
|
||||||
|
// waiting overall, not what's left after the current filter.
|
||||||
|
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
||||||
|
for (const row of db
|
||||||
|
.prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status")
|
||||||
|
.all()) {
|
||||||
|
counts[row.status] = row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
feedback: page,
|
||||||
|
counts,
|
||||||
|
nextCursor: hasMore ? page[page.length - 1].id : null,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
NO_STORE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── PATCH /api/admin/feedback/:id ───────────────────────────────
|
||||||
|
{ status?, admin_note? } — either, both, partial.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
admin.patch("/feedback/:id", requireRole("admin"), async (c) => {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Expected a JSON body." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
if (!STATUSES.includes(body.status)) {
|
||||||
|
return c.json({ error: "Unknown status." }, 422);
|
||||||
|
}
|
||||||
|
sets.push("status = ?");
|
||||||
|
params.push(body.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.admin_note !== undefined) {
|
||||||
|
const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT);
|
||||||
|
sets.push("admin_note = ?");
|
||||||
|
params.push(note || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
|
||||||
|
|
||||||
|
const result = db_update(c.get("db"), id, sets, params);
|
||||||
|
if (!result) return c.json({ error: "No such feedback." }, 404);
|
||||||
|
|
||||||
|
console.log(`feedback #${id} updated by ${c.get("user").email}`);
|
||||||
|
|
||||||
|
return c.json({ feedback: result }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
function db_update(db, id, sets, params) {
|
||||||
|
const changed = db
|
||||||
|
.prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`)
|
||||||
|
.run(...params, id);
|
||||||
|
|
||||||
|
if (changed.changes === 0) return null;
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, created_at, feedback_type, message, name, email,
|
||||||
|
page_path, section_id, status, admin_note
|
||||||
|
FROM feedback WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/admin/feedback/:id ──────────────────────────────
|
||||||
|
Actually gone. Marking something 'spam' is the reversible
|
||||||
|
option and should be the habit; this is for the cases where
|
||||||
|
the content itself shouldn't stay on disk.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
admin.delete("/feedback/:id", requireRole("admin"), (c) => {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
const result = c
|
||||||
|
.get("db")
|
||||||
|
.prepare("DELETE FROM feedback WHERE id = ?")
|
||||||
|
.run(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) return c.json({ error: "No such feedback." }, 404);
|
||||||
|
|
||||||
|
console.log(`feedback #${id} deleted by ${c.get("user").email}`);
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default admin;
|
||||||
99
server/src/routes/auth.js
Normal file
99
server/src/routes/auth.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
AUTH ROUTES
|
||||||
|
|
||||||
|
POST /api/auth/login email + password → session cookie
|
||||||
|
POST /api/auth/logout deletes the session row and cookie
|
||||||
|
GET /api/auth/me who am I, or 401
|
||||||
|
|
||||||
|
Three deliberate choices:
|
||||||
|
|
||||||
|
Login is rate limited where it's mounted, and answers wrong
|
||||||
|
password and unknown account identically. Telling an attacker
|
||||||
|
which addresses exist is free reconnaissance.
|
||||||
|
|
||||||
|
A failed login still runs a hash. Otherwise the response time
|
||||||
|
itself says whether the account exists.
|
||||||
|
|
||||||
|
Nothing here creates accounts. See admin-cli.js.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
destroySession,
|
||||||
|
currentUser,
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
} from "../auth.js";
|
||||||
|
|
||||||
|
const auth = new Hono();
|
||||||
|
|
||||||
|
// A real hash to compare against when the account doesn't exist,
|
||||||
|
// so both paths cost the same. Computed once at import.
|
||||||
|
const DUMMY_HASH = hashPassword("this password is never correct");
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
auth.post("/login", async (c) => {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Expected a JSON body." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 254);
|
||||||
|
const password = String(body.password ?? "");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return c.json({ error: "Email and password are required." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const user = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, email, name, role, password_hash, is_active
|
||||||
|
FROM admin_users
|
||||||
|
WHERE email = ?`,
|
||||||
|
)
|
||||||
|
.get(email);
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
user && user.is_active === 1
|
||||||
|
? verifyPassword(password, user.password_hash)
|
||||||
|
: (verifyPassword(password, DUMMY_HASH), false);
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
console.warn(`login failed for ${email}`);
|
||||||
|
return c.json({ error: "That email and password don't match." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(db, c, user.id);
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET last_login_at = datetime('now') WHERE id = ?").run(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`login ${user.email}`);
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{ user: { id: user.id, email: user.email, name: user.name, role: user.role } },
|
||||||
|
200,
|
||||||
|
NO_STORE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.post("/logout", (c) => {
|
||||||
|
destroySession(c.get("db"), c);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.get("/me", (c) => {
|
||||||
|
const user = currentUser(c.get("db"), c);
|
||||||
|
if (!user) return c.json({ error: "Not signed in." }, 401, NO_STORE);
|
||||||
|
return c.json({ user }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default auth;
|
||||||
141
server/src/routes/people.js
Normal file
141
server/src/routes/people.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PEOPLE ROUTES — read-only, mounted under /api
|
||||||
|
|
||||||
|
GET /teams/:id/people current public members of a team
|
||||||
|
GET /people?ids=a,b,c named people, any order
|
||||||
|
|
||||||
|
The team route reads v_org_leadership, which already decides who
|
||||||
|
counts as current and public — affiliation still open, marked
|
||||||
|
public, person published. Restating those conditions here is how
|
||||||
|
they drift apart.
|
||||||
|
|
||||||
|
That view is affiliation-driven, so it can't serve the lookup
|
||||||
|
route: a person with no affiliations would vanish, and one with
|
||||||
|
several would appear more than once. The lookup reads people
|
||||||
|
directly and returns no title, because a bare slug names no seat.
|
||||||
|
|
||||||
|
Photos are filenames, as everywhere else in this API. The
|
||||||
|
component decides where they live.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { asBool } from "../shape.js";
|
||||||
|
|
||||||
|
const people = 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(",");
|
||||||
|
|
||||||
|
/* How many slugs one request may name. Keeps the URL sane. */
|
||||||
|
const MAX_IDS = 50;
|
||||||
|
|
||||||
|
function shapePerson(row) {
|
||||||
|
return {
|
||||||
|
id: row.id ?? row.person_id,
|
||||||
|
name: row.display_name,
|
||||||
|
// Only the team route knows a seat; the lookup route doesn't.
|
||||||
|
title: row.title ?? null,
|
||||||
|
tagline: row.tagline,
|
||||||
|
pronouns: row.pronouns,
|
||||||
|
photo: row.photo,
|
||||||
|
location_label: row.location_label,
|
||||||
|
public_email: row.public_email,
|
||||||
|
org: row.primary_org_id
|
||||||
|
? { id: row.primary_org_id, name: row.primary_org_name }
|
||||||
|
: null,
|
||||||
|
bio: splitParagraphs(row.bio),
|
||||||
|
role: row.role ?? null,
|
||||||
|
is_owner: row.is_owner === undefined ? false : asBool(row.is_owner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* people.bio is one run of prose with blank lines between
|
||||||
|
paragraphs — unrelated to shape.js's paragraphs(), which turns
|
||||||
|
content_block rows into text. */
|
||||||
|
function splitParagraphs(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const parts = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length ? parts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── One team's people ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
people.get("/teams/:id/people", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const team = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, org_id, name, tagline, color, logo
|
||||||
|
FROM teams
|
||||||
|
WHERE id = ? AND is_published = 1`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!team) return c.json({ error: "No such team" }, 404);
|
||||||
|
|
||||||
|
// The view orders by org first, which isn't what a single team
|
||||||
|
// wants, so the order is restated here.
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM v_org_leadership
|
||||||
|
WHERE team_id = ?
|
||||||
|
ORDER BY is_owner DESC,
|
||||||
|
sort_order,
|
||||||
|
COALESCE(sort_name, display_name),
|
||||||
|
display_name`,
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
|
||||||
|
return json(c, { team, people: rows.map(shapePerson) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── People by id ──────────────────────────────────────────────
|
||||||
|
For hand-picked lists in a section. Order is the caller's, so
|
||||||
|
there's no ORDER BY here.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
people.get("/people", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const ids = [
|
||||||
|
...new Set(
|
||||||
|
(c.req.query("ids") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].slice(0, MAX_IDS);
|
||||||
|
|
||||||
|
if (ids.length === 0) return json(c, { people: [] });
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.id,
|
||||||
|
p.display_name,
|
||||||
|
p.pronouns,
|
||||||
|
p.photo,
|
||||||
|
p.tagline,
|
||||||
|
p.location_label,
|
||||||
|
p.public_email,
|
||||||
|
p.bio,
|
||||||
|
o.id AS primary_org_id,
|
||||||
|
o.name AS primary_org_name
|
||||||
|
FROM people p
|
||||||
|
LEFT JOIN organizations o ON o.id = p.primary_org_id
|
||||||
|
WHERE p.id IN (${marks(ids.length)})
|
||||||
|
AND p.is_published = 1`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
return json(c, { people: rows.map(shapePerson) });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default people;
|
||||||
29
src/App.tsx
29
src/App.tsx
|
|
@ -1,17 +1,31 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
|
||||||
|
/*Components*/
|
||||||
import Layout from "./components/Layout.tsx";
|
import Layout from "./components/Layout.tsx";
|
||||||
|
|
||||||
|
/*Libraries*/
|
||||||
|
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
|
||||||
|
|
||||||
|
/*Primary Pages*/
|
||||||
import Home from "./pages/Home.tsx";
|
import Home from "./pages/Home.tsx";
|
||||||
import Retreats from "./pages/Retreats.tsx";
|
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";
|
||||||
|
|
||||||
|
/*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";
|
||||||
|
|
||||||
|
/*Admin Pages*/
|
||||||
|
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
||||||
|
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
||||||
|
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
||||||
|
import EntityList from "./pages/admin/EntityList.tsx";
|
||||||
|
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
||||||
|
|
||||||
|
/*Ternary Pages*/
|
||||||
import Privacy from "./pages/Privacy.tsx";
|
import Privacy from "./pages/Privacy.tsx";
|
||||||
import Terms from "./pages/Terms.tsx";
|
import Terms from "./pages/Terms.tsx";
|
||||||
|
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
import NotFound from "./pages/NotFound.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -31,6 +45,17 @@ export default function App() {
|
||||||
<Route path="terms" element={<Terms />} />
|
<Route path="terms" element={<Terms />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route element={<AuthProvider><Outlet /></AuthProvider>}>
|
||||||
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<Navigate to="/admin/feedback" replace />} />
|
||||||
|
<Route path="feedback" element={<AdminFeedback />} />
|
||||||
|
<Route path=":entity" element={<EntityList />} />
|
||||||
|
<Route path=":entity/:id" element={<EntityEdit />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,370 @@
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { get } from "../lib/api.js";
|
||||||
import "./PeopleTiles.css";
|
import "./PeopleTiles.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PeopleTiles — a horizontal, polaroid-style people list.
|
* PeopleTiles — a horizontal, polaroid-style people list.
|
||||||
*
|
*
|
||||||
* Drop into any section:
|
* Supply data any of four ways:
|
||||||
* <PeopleTiles size="md" groups={staffGroups} />
|
*
|
||||||
* <PeopleTiles size="sm" people={volunteers} />
|
* <PeopleTiles size="lg" teams="ngu-board" />
|
||||||
|
* <PeopleTiles size="md" teams={["ngu-board", "nw-leadership"]} />
|
||||||
|
* <PeopleTiles size="sm" people={[
|
||||||
|
* { peopleslug: "john-doe", title: "Hospitality" },
|
||||||
|
* { peopleslug: "jane-doe" },
|
||||||
|
* ]} />
|
||||||
|
* <PeopleTiles size="sm" people={volunteers} overflow="scroll" />
|
||||||
|
*
|
||||||
|
* With `teams`, each team is fetched in the order given and split
|
||||||
|
* into a lead group and a members group by the affiliation's
|
||||||
|
* is_owner flag.
|
||||||
|
*
|
||||||
|
* With `peopleslug`, the person is fetched by id and any other key
|
||||||
|
* on the entry overrides what came back — so a title can be given
|
||||||
|
* per placement, and is blank when it isn't. A slug entry can sit
|
||||||
|
* beside a fully-written person in the same array.
|
||||||
*
|
*
|
||||||
* Sizes
|
* Sizes
|
||||||
* sm photo + name
|
* sm photo + name
|
||||||
* md photo + name + title
|
* md photo + name + title
|
||||||
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
|
* lg photo + name + title, and a chevron on anyone who has
|
||||||
|
* something to expand: pronouns, home organization, location,
|
||||||
|
* public email or a bio. Not bio alone — the details panel is
|
||||||
|
* worth opening before anyone has written prose.
|
||||||
*
|
*
|
||||||
* Group shape
|
* Field names follow the API (is_owner, location_label), so a row
|
||||||
* { id, label?, note?, accent?, people: [person] }
|
* from /api/teams/:id/people drops in unchanged.
|
||||||
*
|
|
||||||
* Person shape
|
|
||||||
* {
|
|
||||||
* id, name,
|
|
||||||
* title?, // "Regional Director"
|
|
||||||
* photo?, // "/people/jane-doe.jpg" — falls back to initials
|
|
||||||
* pronouns?, // "she/her"
|
|
||||||
* age?, // number, or use birthdate
|
|
||||||
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
|
|
||||||
* org?, // "Grace Chapel" or { name, href }
|
|
||||||
* bio?, // string or string[] (paragraphs)
|
|
||||||
* accent?, // per-person override
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SIZE_FEATURES = {
|
export interface Person {
|
||||||
sm: { title: false, bio: false },
|
id?: string | number;
|
||||||
md: { title: true, bio: false },
|
name: string;
|
||||||
lg: { title: true, bio: true },
|
title?: string | null;
|
||||||
|
tagline?: string | null;
|
||||||
|
photo?: string | null;
|
||||||
|
pronouns?: string | null;
|
||||||
|
location_label?: string | null;
|
||||||
|
public_email?: string | null;
|
||||||
|
org?: string | { id?: string; name: string; href?: string } | null;
|
||||||
|
bio?: string | string[] | null;
|
||||||
|
accent?: string;
|
||||||
|
role?: string | null;
|
||||||
|
is_owner?: boolean;
|
||||||
|
/** Accepted as an alias so hand-written entries can use either. */
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person named by slug. Any other field overrides the record. */
|
||||||
|
export interface PersonRef extends Partial<Omit<Person, "name">> {
|
||||||
|
peopleslug: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonInput = Person | PersonRef;
|
||||||
|
|
||||||
|
export interface PeopleGroup {
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
note?: string;
|
||||||
|
accent?: string;
|
||||||
|
people: Person[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleGroupInput extends Omit<PeopleGroup, "people"> {
|
||||||
|
people: PersonInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSpec {
|
||||||
|
id: string;
|
||||||
|
/** Heading for the members group. Defaults to the team's name. */
|
||||||
|
label?: string;
|
||||||
|
/** Heading for the lead group. Defaults to the lead's own title. */
|
||||||
|
leadLabel?: string;
|
||||||
|
/** Set false to keep owners inline with everyone else. */
|
||||||
|
splitOwners?: boolean;
|
||||||
|
accent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamSource = string | TeamSpec;
|
||||||
|
|
||||||
|
export type PeopleTilesSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface PeopleTilesProps
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
||||||
|
people?: PersonInput[];
|
||||||
|
groups?: PeopleGroupInput[];
|
||||||
|
teams?: TeamSource | TeamSource[];
|
||||||
|
size?: PeopleTilesSize;
|
||||||
|
scale?: number;
|
||||||
|
overflow?: "wrap" | "scroll";
|
||||||
|
align?: "start" | "center";
|
||||||
|
accent?: string;
|
||||||
|
tilt?: boolean;
|
||||||
|
/** How long a fetched team or person is reused, in ms. */
|
||||||
|
ttl?: number;
|
||||||
|
emptyMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
onExpand?: (person: Person | null, group: PeopleGroup | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_FEATURES: Record<PeopleTilesSize, { title: boolean; details: boolean }> = {
|
||||||
|
sm: { title: false, details: false },
|
||||||
|
md: { title: true, details: false },
|
||||||
|
lg: { title: true, details: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Data ──────────────────────────────────────────────────────
|
||||||
|
Fetching lives here rather than in every section, but the view
|
||||||
|
below stays pure — it only ever sees resolved groups, whatever
|
||||||
|
produced them.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function PeopleTiles({
|
export default function PeopleTiles({
|
||||||
|
teams,
|
||||||
|
groups,
|
||||||
|
people,
|
||||||
|
ttl = 5 * 60_000,
|
||||||
|
loadingMessage = "Loading…",
|
||||||
|
errorMessage = "Couldn't load this list right now.",
|
||||||
|
...view
|
||||||
|
}: PeopleTilesProps) {
|
||||||
|
const specs = useMemo(() => normalizeTeams(teams), [teams]);
|
||||||
|
const teamKey = specs.map((spec) => spec.id).join(",");
|
||||||
|
|
||||||
|
// Sorted so two sections naming the same people in a different
|
||||||
|
// order still hit the same cached request.
|
||||||
|
const slugs = useMemo(
|
||||||
|
() => (specs.length ? [] : collectSlugs(groups, people)),
|
||||||
|
[specs.length, groups, people],
|
||||||
|
);
|
||||||
|
const slugKey = slugs.join(",");
|
||||||
|
|
||||||
|
const [teamGroups, setTeamGroups] = useState<PeopleGroup[] | null>(null);
|
||||||
|
const [directory, setDirectory] = useState<Record<string, Person> | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!specs.length) {
|
||||||
|
setTeamGroups(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = true;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
specs.map((spec) =>
|
||||||
|
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
|
||||||
|
spec,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (!live) return;
|
||||||
|
// Order follows the order the teams were supplied in, not
|
||||||
|
// whichever request came back first.
|
||||||
|
setTeamGroups(
|
||||||
|
results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!live) return;
|
||||||
|
console.error("PeopleTiles: couldn't load teams", teamKey, err);
|
||||||
|
setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [teamKey, ttl, specs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugKey) {
|
||||||
|
setDirectory(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = true;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
||||||
|
.then((data: { people: Person[] }) => {
|
||||||
|
if (!live) return;
|
||||||
|
const byId: Record<string, Person> = {};
|
||||||
|
for (const person of data.people) byId[String(person.id)] = person;
|
||||||
|
setDirectory(byId);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!live) return;
|
||||||
|
console.error("PeopleTiles: couldn't load people", slugKey, err);
|
||||||
|
setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [slugKey, ttl]);
|
||||||
|
|
||||||
|
if (specs.length) {
|
||||||
|
if (failed) return <p className="pl__empty">{errorMessage}</p>;
|
||||||
|
if (!teamGroups) return <p className="pl__empty">{loadingMessage}</p>;
|
||||||
|
return <PeopleTilesView {...view} groups={teamGroups} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slugKey) {
|
||||||
|
if (failed) return <p className="pl__empty">{errorMessage}</p>;
|
||||||
|
if (!directory) return <p className="pl__empty">{loadingMessage}</p>;
|
||||||
|
return (
|
||||||
|
<PeopleTilesView
|
||||||
|
{...view}
|
||||||
|
groups={groups?.map((group) => ({
|
||||||
|
...group,
|
||||||
|
people: resolveAll(group.people, directory),
|
||||||
|
}))}
|
||||||
|
people={people ? resolveAll(people, directory) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PeopleTilesView
|
||||||
|
{...view}
|
||||||
|
groups={groups as PeopleGroup[] | undefined}
|
||||||
|
people={people as Person[] | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamResponse {
|
||||||
|
team: { id: string; name: string; color?: string | null };
|
||||||
|
people: Person[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] {
|
||||||
|
if (!teams) return [];
|
||||||
|
const list = Array.isArray(teams) ? teams : [teams];
|
||||||
|
return list
|
||||||
|
.map((entry) => (typeof entry === "string" ? { id: entry } : entry))
|
||||||
|
.filter((spec): spec is TeamSpec => Boolean(spec?.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRef(entry: PersonInput): entry is PersonRef {
|
||||||
|
return typeof (entry as PersonRef).peopleslug === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSlugs(
|
||||||
|
groups?: PeopleGroupInput[],
|
||||||
|
people?: PersonInput[],
|
||||||
|
): string[] {
|
||||||
|
const found = new Set<string>();
|
||||||
|
const scan = (list?: PersonInput[]) => {
|
||||||
|
for (const entry of list ?? []) {
|
||||||
|
if (entry && isRef(entry)) found.add(entry.peopleslug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scan(people);
|
||||||
|
for (const group of groups ?? []) scan(group.people);
|
||||||
|
return [...found].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The fetched record is the base; anything else on the entry wins,
|
||||||
|
including an explicit null — that's how a title is deliberately
|
||||||
|
left blank rather than inherited. */
|
||||||
|
function resolveAll(
|
||||||
|
list: PersonInput[],
|
||||||
|
directory: Record<string, Person>,
|
||||||
|
): Person[] {
|
||||||
|
const resolved: Person[] = [];
|
||||||
|
|
||||||
|
for (const entry of list) {
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
if (!isRef(entry)) {
|
||||||
|
resolved.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = directory[entry.peopleslug];
|
||||||
|
if (!base) {
|
||||||
|
// Unpublished, deleted, or a typo in the slug. Leaving the
|
||||||
|
// tile out beats rendering a nameless placeholder.
|
||||||
|
console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { peopleslug, ...overrides } = entry;
|
||||||
|
const merged: Person = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
resolved.push(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTeamGroups(
|
||||||
|
spec: TeamSpec,
|
||||||
|
data: TeamResponse,
|
||||||
|
teamCount: number,
|
||||||
|
): PeopleGroup[] {
|
||||||
|
const accent = spec.accent ?? data.team.color ?? undefined;
|
||||||
|
const name = spec.label ?? data.team.name;
|
||||||
|
const split = spec.splitOwners !== false;
|
||||||
|
|
||||||
|
const owners = split ? data.people.filter(owns) : [];
|
||||||
|
const rest = split ? data.people.filter((person) => !owns(person)) : data.people;
|
||||||
|
|
||||||
|
// A lead only reads as a lead when there's a body of people to
|
||||||
|
// stand apart from. All owners, or none, is just a list.
|
||||||
|
if (!owners.length || !rest.length) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: data.team.id,
|
||||||
|
// One unlabelled team needs no heading; several always do.
|
||||||
|
label: teamCount > 1 || spec.label ? name : undefined,
|
||||||
|
accent,
|
||||||
|
people: data.people,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${data.team.id}-lead`,
|
||||||
|
label: spec.leadLabel ?? leadLabel(owners, name),
|
||||||
|
accent,
|
||||||
|
people: owners,
|
||||||
|
},
|
||||||
|
{ id: data.team.id, label: name, accent, people: rest },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadLabel(owners: Person[], teamName: string): string {
|
||||||
|
if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string;
|
||||||
|
return `${teamName} lead${owners.length > 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── View ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function PeopleTilesView({
|
||||||
people,
|
people,
|
||||||
groups,
|
groups,
|
||||||
size = "md",
|
size = "md",
|
||||||
scale = 1,
|
scale = 1,
|
||||||
overflow = "wrap", // "wrap" | "scroll"
|
overflow = "wrap",
|
||||||
align = "start", // "start" | "center"
|
align = "start",
|
||||||
accent,
|
accent,
|
||||||
tilt = false,
|
tilt = false,
|
||||||
emptyMessage = "No one listed yet.",
|
emptyMessage = "No one listed yet.",
|
||||||
|
|
@ -50,18 +372,24 @@ export default function PeopleTiles({
|
||||||
className = "",
|
className = "",
|
||||||
style,
|
style,
|
||||||
...rest
|
...rest
|
||||||
|
}: Omit<
|
||||||
|
PeopleTilesProps,
|
||||||
|
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
|
||||||
|
> & {
|
||||||
|
people?: Person[];
|
||||||
|
groups?: PeopleGroup[];
|
||||||
}) {
|
}) {
|
||||||
const baseId = useId().replace(/:/g, "");
|
const baseId = useId().replace(/:/g, "");
|
||||||
const [openKey, setOpenKey] = useState(null);
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||||
const rootRef = useRef(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
|
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
|
||||||
const features = SIZE_FEATURES[resolvedSize];
|
const features = SIZE_FEATURES[resolvedSize];
|
||||||
|
|
||||||
const resolvedGroups = useMemo(() => {
|
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
|
||||||
const source = Array.isArray(groups) && groups.length
|
const source = groups?.length
|
||||||
? groups
|
? groups
|
||||||
: Array.isArray(people) && people.length
|
: people?.length
|
||||||
? [{ id: "all", people }]
|
? [{ id: "all", people }]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
@ -74,7 +402,7 @@ export default function PeopleTiles({
|
||||||
.filter((group) => group.people.length > 0);
|
.filter((group) => group.people.length > 0);
|
||||||
}, [groups, people]);
|
}, [groups, people]);
|
||||||
|
|
||||||
// Close the bio if the person it belongs to disappears from the data.
|
// Close the panel if the person it belongs to disappears.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openKey) return;
|
if (!openKey) return;
|
||||||
const stillThere = resolvedGroups.some((group) =>
|
const stillThere = resolvedGroups.some((group) =>
|
||||||
|
|
@ -87,21 +415,23 @@ export default function PeopleTiles({
|
||||||
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
|
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
|
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
|
||||||
|
|
||||||
function toggle(group, person, index) {
|
function toggle(group: PeopleGroup, person: Person, index: number) {
|
||||||
const key = keyFor(group, person, index);
|
const key = keyFor(group, person, index);
|
||||||
const next = openKey === key ? null : key;
|
const next = openKey === key ? null : key;
|
||||||
setOpenKey(next);
|
setOpenKey(next);
|
||||||
if (onExpand) onExpand(next ? person : null, next ? group : null);
|
onExpand?.(next ? person : null, next ? group : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === "Escape" && openKey) {
|
if (event.key === "Escape" && openKey) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setOpenKey(null);
|
setOpenKey(null);
|
||||||
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
|
const button = rootRef.current?.querySelector<HTMLButtonElement>(
|
||||||
if (button) button.focus();
|
'.pl__tile[aria-expanded="true"]',
|
||||||
|
);
|
||||||
|
button?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +443,13 @@ export default function PeopleTiles({
|
||||||
data-overflow={overflow}
|
data-overflow={overflow}
|
||||||
data-align={align}
|
data-align={align}
|
||||||
data-tilt={tilt ? "on" : "off"}
|
data-tilt={tilt ? "on" : "off"}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||||
...(accent ? { "--pl-accent": accent } : null),
|
...(accent ? { "--pl-accent": accent } : null),
|
||||||
...style,
|
...style,
|
||||||
}}
|
} as CSSProperties
|
||||||
|
}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
|
|
@ -126,7 +458,9 @@ export default function PeopleTiles({
|
||||||
<section
|
<section
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className="pl__group"
|
className="pl__group"
|
||||||
style={group.accent ? { "--pl-accent": group.accent } : undefined}
|
style={
|
||||||
|
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
|
||||||
|
}
|
||||||
aria-label={group.label || undefined}
|
aria-label={group.label || undefined}
|
||||||
>
|
>
|
||||||
{(group.label || group.note) && (
|
{(group.label || group.note) && (
|
||||||
|
|
@ -139,14 +473,18 @@ export default function PeopleTiles({
|
||||||
<ul className="pl__row">
|
<ul className="pl__row">
|
||||||
{group.people.map((person, index) => {
|
{group.people.map((person, index) => {
|
||||||
const key = keyFor(group, person, index);
|
const key = keyFor(group, person, index);
|
||||||
const expandable = features.bio && hasBio(person);
|
const expandable = features.details && hasDetails(person);
|
||||||
const isOpen = expandable && openKey === key;
|
const isOpen = expandable && openKey === key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={key}
|
key={key}
|
||||||
className="pl__item"
|
className="pl__item"
|
||||||
style={person.accent ? { "--pl-accent": person.accent } : undefined}
|
style={
|
||||||
|
person.accent
|
||||||
|
? ({ "--pl-accent": person.accent } as CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tile
|
<Tile
|
||||||
person={person}
|
person={person}
|
||||||
|
|
@ -165,13 +503,13 @@ export default function PeopleTiles({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<BioPanel
|
<DetailPanel
|
||||||
id={`${baseId}-bio`}
|
id={`${baseId}-bio`}
|
||||||
person={open.person}
|
person={open.person}
|
||||||
group={open.group}
|
group={open.group}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpenKey(null);
|
setOpenKey(null);
|
||||||
if (onExpand) onExpand(null, null);
|
onExpand?.(null, null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -179,16 +517,31 @@ export default function PeopleTiles({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
function Tile({
|
||||||
|
person,
|
||||||
|
showTitle,
|
||||||
|
expandable,
|
||||||
|
isOpen,
|
||||||
|
panelId,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
person: Person;
|
||||||
|
showTitle: boolean;
|
||||||
|
expandable: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
panelId: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const title = titleOf(person);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
|
||||||
<span className="pl__frame">
|
<span className="pl__frame">
|
||||||
<span className="pl__photo">
|
<span className="pl__photo">
|
||||||
<Photo src={person.photo} name={person.name} />
|
<Photo src={photoSrc(person.photo)} name={person.name} />
|
||||||
</span>
|
</span>
|
||||||
<span className="pl__caption">
|
<span className="pl__caption">
|
||||||
<span className="pl__name">{person.name}</span>
|
<span className="pl__name">{person.name}</span>
|
||||||
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
|
{showTitle && title && <span className="pl__title">{title}</span>}
|
||||||
</span>
|
</span>
|
||||||
{expandable && (
|
{expandable && (
|
||||||
<span className="pl__badge" aria-hidden="true">
|
<span className="pl__badge" aria-hidden="true">
|
||||||
|
|
@ -196,7 +549,6 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!expandable) {
|
if (!expandable) {
|
||||||
|
|
@ -212,12 +564,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
|
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Photo({ src, name }) {
|
function Photo({ src, name }: { src?: string | null; name: string }) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -244,10 +596,21 @@ function Photo({ src, name }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BioPanel({ id, person, group, onClose }) {
|
function DetailPanel({
|
||||||
const age = resolveAge(person);
|
id,
|
||||||
|
person,
|
||||||
|
group,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
person: Person;
|
||||||
|
group: PeopleGroup;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const org = resolveOrg(person.org);
|
const org = resolveOrg(person.org);
|
||||||
|
const title = titleOf(person);
|
||||||
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
|
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
|
||||||
|
const tint = person.accent || group?.accent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -255,20 +618,19 @@ function BioPanel({ id, person, group, onClose }) {
|
||||||
className="pl__bio"
|
className="pl__bio"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={`About ${person.name}`}
|
aria-label={`About ${person.name}`}
|
||||||
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
|
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
|
||||||
>
|
>
|
||||||
<div className="pl__bio-head">
|
<div className="pl__bio-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="pl__bio-name">{person.name}</p>
|
<p className="pl__bio-name">{person.name}</p>
|
||||||
{person.title && <p className="pl__bio-title">{person.title}</p>}
|
{title && <p className="pl__bio-title">{title}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="pl__close" onClick={onClose}>
|
<button type="button" className="pl__close" onClick={onClose}>
|
||||||
<span className="pl__sr">Close bio</span>
|
<span className="pl__sr">Close details</span>
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(person.pronouns || age != null || org) && (
|
|
||||||
<dl className="pl__facts">
|
<dl className="pl__facts">
|
||||||
{person.pronouns && (
|
{person.pronouns && (
|
||||||
<div className="pl__fact">
|
<div className="pl__fact">
|
||||||
|
|
@ -276,12 +638,6 @@ function BioPanel({ id, person, group, onClose }) {
|
||||||
<dd>{person.pronouns}</dd>
|
<dd>{person.pronouns}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{age != null && (
|
|
||||||
<div className="pl__fact">
|
|
||||||
<dt>Age</dt>
|
|
||||||
<dd>{age}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{org && (
|
{org && (
|
||||||
<div className="pl__fact">
|
<div className="pl__fact">
|
||||||
<dt>Home organization</dt>
|
<dt>Home organization</dt>
|
||||||
|
|
@ -296,8 +652,21 @@ function BioPanel({ id, person, group, onClose }) {
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
{person.location_label && (
|
||||||
|
<div className="pl__fact">
|
||||||
|
<dt>Based in</dt>
|
||||||
|
<dd>{person.location_label}</dd>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{person.public_email && (
|
||||||
|
<div className="pl__fact">
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={`mailto:${person.public_email}`}>{person.public_email}</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
||||||
<p key={index} className="pl__bio-text">
|
<p key={index} className="pl__bio-text">
|
||||||
|
|
@ -323,13 +692,13 @@ function Chevron() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helpers ------------------------------------------------------------- */
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function keyFor(group, person, index) {
|
function keyFor(group: PeopleGroup, person: Person, index: number): string {
|
||||||
return `${group.id}:${person.id ?? person.name ?? index}`;
|
return `${group.id}:${person.id ?? person.name ?? index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findByKey(groups, key) {
|
function findByKey(groups: PeopleGroup[], key: string | null) {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
for (let index = 0; index < group.people.length; index += 1) {
|
for (let index = 0; index < group.people.length; index += 1) {
|
||||||
|
|
@ -340,12 +709,36 @@ function findByKey(groups, key) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBio(person) {
|
/* The seat they hold here, or what they're called when there is no
|
||||||
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
|
seat — the same fallback content.js applies to leadership rows. */
|
||||||
return Boolean(person.bio);
|
function titleOf(person: Person): string | null {
|
||||||
|
return person.title || person.tagline || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initials(name = "") {
|
function owns(person: Person): boolean {
|
||||||
|
return Boolean(person.is_owner ?? person.isOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anything the panel would have to show. Gating on bio alone left
|
||||||
|
every tile flat until someone wrote prose. */
|
||||||
|
function hasDetails(person: Person): boolean {
|
||||||
|
if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(
|
||||||
|
person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Database rows carry a bare filename; a hand-written entry may
|
||||||
|
give a path or a full URL. Both should work. */
|
||||||
|
function photoSrc(photo?: string | null): string | null {
|
||||||
|
if (!photo) return null;
|
||||||
|
if (/^(https?:|\/|data:)/.test(photo)) return photo;
|
||||||
|
return `/people/${photo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name = ""): string {
|
||||||
return name
|
return name
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
|
|
@ -355,21 +748,9 @@ function initials(name = "") {
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAge(person) {
|
function resolveOrg(org: Person["org"]) {
|
||||||
if (typeof person.age === "number") return person.age;
|
|
||||||
if (!person.birthdate) return null;
|
|
||||||
const born = new Date(person.birthdate);
|
|
||||||
if (Number.isNaN(born.getTime())) return null;
|
|
||||||
const now = new Date();
|
|
||||||
let age = now.getFullYear() - born.getFullYear();
|
|
||||||
const monthDelta = now.getMonth() - born.getMonth();
|
|
||||||
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
|
|
||||||
return age >= 0 ? age : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOrg(org) {
|
|
||||||
if (!org) return null;
|
if (!org) return null;
|
||||||
if (typeof org === "string") return { name: org };
|
if (typeof org === "string") return { name: org, href: undefined };
|
||||||
if (!org.name) return null;
|
if (!org.name) return null;
|
||||||
return org;
|
return org;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
418
src/components/admin/fields.tsx
Normal file
418
src/components/admin/fields.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN FORM PRIMITIVES
|
||||||
|
|
||||||
|
Field renders one input from a manifest entry. Repeater renders
|
||||||
|
an ordered collection of them, and nests one level for content
|
||||||
|
blocks and their items.
|
||||||
|
|
||||||
|
Ordering is array position — the server writes sort_order from
|
||||||
|
the index — so moving a row is a splice, not a number to
|
||||||
|
hand-edit. Rows can be dragged by the handle or moved with the
|
||||||
|
arrow buttons; the arrows are the keyboard path and stay whether
|
||||||
|
or not a pointer is in use.
|
||||||
|
|
||||||
|
A row added and never filled in is dropped by the server rather
|
||||||
|
than rejected, which depends on spec.blank seeding nothing the
|
||||||
|
server doesn't also declare as a column default. If you give a
|
||||||
|
blank row a starting value here, add the matching default: to
|
||||||
|
that column in the server's admin-schema.js or the row will be
|
||||||
|
saved as real input.
|
||||||
|
|
||||||
|
Two options change the box itself rather than what goes in it:
|
||||||
|
|
||||||
|
prefix fixed text inside the box, left of the cursor. The
|
||||||
|
value it decorates is only the part after it, so
|
||||||
|
the caller joins the two. Used by composed slugs,
|
||||||
|
where the prefix is a fact about another field
|
||||||
|
rather than something to retype.
|
||||||
|
readOnly shown, selectable, not editable. Deliberately not
|
||||||
|
`disabled`: a disabled control reads as switched
|
||||||
|
off and drops out of the tab order, whereas an
|
||||||
|
immutable id is settled fact you still want to be
|
||||||
|
able to read and copy.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
const input =
|
||||||
|
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
||||||
|
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||||
|
|
||||||
|
/* Same box, but lit by the real input nested inside it. */
|
||||||
|
const inputShell =
|
||||||
|
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
|
||||||
|
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
|
||||||
|
"focus-within:ring-[#138ba0]/25";
|
||||||
|
|
||||||
|
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||||
|
const shellError =
|
||||||
|
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
|
||||||
|
|
||||||
|
/* Reads as settled fact rather than as an empty box someone forgot
|
||||||
|
to fill in. */
|
||||||
|
const inputLocked =
|
||||||
|
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
|
||||||
|
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
|
||||||
|
|
||||||
|
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function getPath(object, path) {
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPath(object, path, value) {
|
||||||
|
const [head, ...rest] = path.split(".");
|
||||||
|
if (rest.length === 0) return { ...object, [head]: value };
|
||||||
|
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Field({ field, value, row, options, error, onChange }) {
|
||||||
|
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||||
|
const widget = field.widget ?? "text";
|
||||||
|
const locked = Boolean(field.readOnly);
|
||||||
|
|
||||||
|
let list = null;
|
||||||
|
let orphaned = false;
|
||||||
|
|
||||||
|
if (widget === "select") {
|
||||||
|
list = field.optionsFrom
|
||||||
|
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
||||||
|
: (field.options ?? []).map((o) =>
|
||||||
|
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
||||||
|
);
|
||||||
|
if (field.filterBy && row) {
|
||||||
|
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored value with no matching option renders as the blank
|
||||||
|
// choice, which reads as "nobody set this" and saves as a
|
||||||
|
// deliberate clear. It usually means the row it pointed at was
|
||||||
|
// deleted, so keep it on screen and say so.
|
||||||
|
orphaned =
|
||||||
|
value != null &&
|
||||||
|
value !== "" &&
|
||||||
|
!list.some(([optionId]) => String(optionId) === String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
id,
|
||||||
|
className: `${input} ${error ? inputError : ""}`,
|
||||||
|
value: value ?? "",
|
||||||
|
onChange: (e) => onChange(e.target.value),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={field.full ? "sm:col-span-2" : ""}>
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||||
|
{field.label}
|
||||||
|
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{widget === "checkbox" ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === 1 || value === true}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
||||||
|
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{field.help ?? "Yes"}
|
||||||
|
</label>
|
||||||
|
) : widget === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
{...common}
|
||||||
|
rows={4}
|
||||||
|
readOnly={locked}
|
||||||
|
className={`${locked ? inputLocked : common.className} resize-y`}
|
||||||
|
/>
|
||||||
|
) : widget === "select" ? (
|
||||||
|
// A select has no readOnly, so this one really does have
|
||||||
|
// to be disabled — there's no way to keep it focusable
|
||||||
|
// and still refuse a new choice.
|
||||||
|
<select
|
||||||
|
{...common}
|
||||||
|
disabled={locked}
|
||||||
|
className={`${locked ? inputLocked : common.className} ${
|
||||||
|
orphaned && !locked ? inputError : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||||
|
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||||
|
{list.map(([id2, label]) => (
|
||||||
|
<option key={id2} value={id2}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : widget === "color" ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
{...common}
|
||||||
|
readOnly={locked}
|
||||||
|
placeholder="#138ba0"
|
||||||
|
className={locked ? inputLocked : common.className}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : field.prefix && !locked ? (
|
||||||
|
// The span is not a form control, so it can't be typed
|
||||||
|
// into, tabbed to, or selected by dragging through the
|
||||||
|
// field. Clicking it focuses the input, which is what
|
||||||
|
// makes the two read as one box.
|
||||||
|
<div
|
||||||
|
className={`${inputShell} ${error ? shellError : ""}`}
|
||||||
|
onClick={() => document.getElementById(id)?.focus()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`shrink-0 select-none ${
|
||||||
|
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{field.prefix}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
||||||
|
step={widget === "number" ? "any" : undefined}
|
||||||
|
{...common}
|
||||||
|
readOnly={locked}
|
||||||
|
aria-readonly={locked || undefined}
|
||||||
|
className={locked ? inputLocked : common.className}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
|
||||||
|
) : orphaned && !locked ? (
|
||||||
|
<p className="mt-1 text-xs text-[#b3261e]">
|
||||||
|
This points at something that has been deleted. Pick a replacement before saving.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
field.help &&
|
||||||
|
widget !== "checkbox" && (
|
||||||
|
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldGrid({ children }) {
|
||||||
|
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repeater ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||||
|
const list = rows ?? [];
|
||||||
|
|
||||||
|
// Which row is in flight, and which one it's currently over.
|
||||||
|
// Both are per-Repeater, which is what keeps a drag inside a
|
||||||
|
// nested collection from being accepted by the outer one.
|
||||||
|
const [dragIndex, setDragIndex] = useState(null);
|
||||||
|
const [overIndex, setOverIndex] = useState(null);
|
||||||
|
const rowRefs = useRef([]);
|
||||||
|
|
||||||
|
const update = (index, next) =>
|
||||||
|
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||||
|
|
||||||
|
const move = (index, delta) => {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= list.length) return;
|
||||||
|
const next = [...list];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const relocate = (from, to) => {
|
||||||
|
if (from === to || from == null || to == null) return;
|
||||||
|
const next = [...list];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([...list, { ...spec.blank }])}
|
||||||
|
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
{spec.addLabel ?? "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
|
||||||
|
|
||||||
|
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{list.map((row, index) => {
|
||||||
|
const dragging = dragIndex === index;
|
||||||
|
const over = overIndex === index && dragIndex !== null && !dragging;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={(el) => {
|
||||||
|
rowRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
// A null dragIndex means the drag started in some
|
||||||
|
// other collection — leave it to whoever owns it.
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (overIndex !== index) setOverIndex(index);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
relocate(dragIndex, index);
|
||||||
|
endDrag();
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
|
||||||
|
(over
|
||||||
|
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
|
||||||
|
: "border-[#4a6b72]/20 ") +
|
||||||
|
(dragging ? "opacity-50" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragIndex(index);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
// Firefox won't start a drag without payload.
|
||||||
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
|
// Drag the whole row, not just the handle.
|
||||||
|
const el = rowRefs.current[index];
|
||||||
|
if (el) e.dataTransfer.setDragImage(el, 16, 16);
|
||||||
|
}}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
title="Drag to reorder"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-[#26454c]">
|
||||||
|
{spec.title ? spec.title(row, options) : `#${index + 1}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-1">
|
||||||
|
<IconButton
|
||||||
|
label="Move up"
|
||||||
|
onClick={() => move(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label="Move down"
|
||||||
|
onClick={() => move(index, 1)}
|
||||||
|
disabled={index === list.length - 1}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label="Remove"
|
||||||
|
danger
|
||||||
|
onClick={() => onChange(list.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldGrid>
|
||||||
|
{spec.fields.map((field) => (
|
||||||
|
<Field
|
||||||
|
key={field.path}
|
||||||
|
field={field}
|
||||||
|
row={row}
|
||||||
|
value={row[field.path]}
|
||||||
|
options={options}
|
||||||
|
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
|
||||||
|
onChange={(value) => update(index, { ...row, [field.path]: value })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FieldGrid>
|
||||||
|
|
||||||
|
{(spec.children ?? []).map((nested) => (
|
||||||
|
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||||
|
<Repeater
|
||||||
|
spec={nested}
|
||||||
|
rows={row[nested.key]}
|
||||||
|
options={options}
|
||||||
|
errors={errors}
|
||||||
|
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||||
|
onChange={(value) => update(index, { ...row, [nested.key]: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
|
||||||
|
(disabled
|
||||||
|
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
|
||||||
|
: danger
|
||||||
|
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
|
||||||
|
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/data/feedbackTypes.js
Normal file
51
src/data/feedbackTypes.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FEEDBACK TYPES
|
||||||
|
|
||||||
|
Used by the public form (to render the tiles) and the admin
|
||||||
|
triage view (to label them). The ids must match TYPES in
|
||||||
|
server/src/routes/feedback.js — that list stays separate because
|
||||||
|
the server isn't in this workspace, and it's the thing that
|
||||||
|
decides what's storable.
|
||||||
|
|
||||||
|
'general' isn't offered anywhere; it's the server's fallback for
|
||||||
|
a type it doesn't recognise, so old rows still read sensibly.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export const FEEDBACK_TYPES = [
|
||||||
|
{
|
||||||
|
id: "broken",
|
||||||
|
label: "Something's broken",
|
||||||
|
hint: "A link, image, or button that doesn't work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "confusing",
|
||||||
|
label: "Hard to use",
|
||||||
|
hint: "Something you couldn't find or follow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outdated",
|
||||||
|
label: "Wrong or missing info",
|
||||||
|
hint: "Old dates, typos, an event that isn't listed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "request",
|
||||||
|
label: "Feature request",
|
||||||
|
hint: "Something you'd like the site to do",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "praise",
|
||||||
|
label: "Kind words",
|
||||||
|
hint: "Tell us what's working well",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
label: "Something else",
|
||||||
|
hint: "Anything that doesn't fit the boxes above",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_ID = new Map(FEEDBACK_TYPES.map((t) => [t.id, t.label]));
|
||||||
|
|
||||||
|
export function feedbackTypeLabel(id) {
|
||||||
|
return BY_ID.get(id) ?? (id === "general" ? "Unsorted" : id);
|
||||||
|
}
|
||||||
614
src/lib/adminSchema.js
Normal file
614
src/lib/adminSchema.js
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN FORM MANIFESTS
|
||||||
|
|
||||||
|
The presentation half of server/src/admin-schema.js: labels,
|
||||||
|
widgets, grouping, and which select pulls from which option
|
||||||
|
list. The server decides what's storable; this decides what the
|
||||||
|
form looks like. Paths here must match column names there, and
|
||||||
|
nested paths ('region.scope') match the side-table keys.
|
||||||
|
|
||||||
|
Adding a field is one entry. Adding an entity is one object here
|
||||||
|
plus one descriptor on the server — no new page component.
|
||||||
|
|
||||||
|
A value seeded in a repeater's `blank` must also be declared as
|
||||||
|
a default: on the matching server column, or a row the user adds
|
||||||
|
and never fills in reads as real input instead of being skipped.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const PLACE_FIELDS = [
|
||||||
|
{ path: "venue", label: "Venue" },
|
||||||
|
{ path: "address", label: "Address", full: true },
|
||||||
|
{ path: "locality", label: "City" },
|
||||||
|
{ path: "state_code", label: "State", help: "Two letters, US only" },
|
||||||
|
{ path: "country", label: "Country", help: "Defaults to US" },
|
||||||
|
{ path: "location_label", label: "Display location", help: "Overrides the line built from city and state" },
|
||||||
|
{ path: "latitude", label: "Latitude", widget: "number" },
|
||||||
|
{ path: "longitude", label: "Longitude", widget: "number" },
|
||||||
|
{ path: "is_online", label: "Online", widget: "checkbox" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PUBLISH_FIELDS = [
|
||||||
|
{ path: "is_published", label: "Published", widget: "checkbox" },
|
||||||
|
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The parts of an affiliation that read the same from either end —
|
||||||
|
the person's list of roles, and the team's list of members. */
|
||||||
|
const AFFILIATION_ROLE_FIELDS = [
|
||||||
|
{ path: "title", label: "Title", help: "Board Chair, Chapter Lead" },
|
||||||
|
{
|
||||||
|
path: "role",
|
||||||
|
label: "Role",
|
||||||
|
widget: "select",
|
||||||
|
options: ["lead", "board", "staff", "volunteer", "member"],
|
||||||
|
},
|
||||||
|
{ path: "is_owner", label: "Owner", widget: "checkbox", help: "Listed first" },
|
||||||
|
{ path: "started_on", label: "Started", widget: "date" },
|
||||||
|
{ path: "ended_on", label: "Ended", widget: "date", help: "Blank means current" },
|
||||||
|
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The two collections every entity carries. */
|
||||||
|
const linksChild = {
|
||||||
|
key: "links",
|
||||||
|
label: "Links and socials",
|
||||||
|
addLabel: "Add link",
|
||||||
|
title: (row) => row.label || row.url || "New link",
|
||||||
|
blank: { kind: "action", label: "", url: "", is_primary: 0 },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "kind",
|
||||||
|
label: "Kind",
|
||||||
|
widget: "select",
|
||||||
|
options: ["action", "social", "website", "email"],
|
||||||
|
},
|
||||||
|
{ path: "platform", label: "Platform", help: "instagram, discord…" },
|
||||||
|
{ path: "label", label: "Label", required: true },
|
||||||
|
{ path: "url", label: "URL", required: true, full: true },
|
||||||
|
{ path: "is_primary", label: "Primary", widget: "checkbox" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocksChild = {
|
||||||
|
key: "content_blocks",
|
||||||
|
label: "Content blocks",
|
||||||
|
addLabel: "Add block",
|
||||||
|
title: (row) => `${row.type ?? "block"} — ${(row.text ?? "").slice(0, 40) || "empty"}`,
|
||||||
|
blank: { slot: "body", type: "paragraph", text: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "slot", label: "Slot", widget: "select", options: ["card", "body"] },
|
||||||
|
{
|
||||||
|
path: "type",
|
||||||
|
label: "Type",
|
||||||
|
widget: "select",
|
||||||
|
options: [
|
||||||
|
"heading",
|
||||||
|
"subheading",
|
||||||
|
"paragraph",
|
||||||
|
"list",
|
||||||
|
"links",
|
||||||
|
"quote",
|
||||||
|
"image",
|
||||||
|
"divider",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "text", label: "Text", widget: "textarea", full: true },
|
||||||
|
{ path: "media", label: "Media", help: "Filename or URL" },
|
||||||
|
{ path: "href", label: "Link" },
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Items",
|
||||||
|
addLabel: "Add item",
|
||||||
|
title: (row) => row.text || "New item",
|
||||||
|
blank: { text: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "text", label: "Text", required: true },
|
||||||
|
{ path: "detail", label: "Detail" },
|
||||||
|
{ path: "url", label: "URL", help: "Blank for a plain list item" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const organizations = {
|
||||||
|
key: "organizations",
|
||||||
|
label: "Organizations",
|
||||||
|
singular: "organization",
|
||||||
|
idLabel: "Slug",
|
||||||
|
slugFrom: "name",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name", primary: true },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "locality", label: "City" },
|
||||||
|
{ key: "state_code", label: "State" },
|
||||||
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: "kind", label: "Kind", options: ["national", "region", "chapter", "partner"] },
|
||||||
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Identity",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "kind",
|
||||||
|
label: "Kind",
|
||||||
|
widget: "select",
|
||||||
|
options: ["national", "region", "chapter", "partner"],
|
||||||
|
required: true,
|
||||||
|
help: "Changing this swaps which extra fields apply",
|
||||||
|
},
|
||||||
|
{ path: "name", label: "Name", required: true },
|
||||||
|
{ path: "short_name", label: "Short name" },
|
||||||
|
{ path: "tagline", label: "Tagline", full: true },
|
||||||
|
{ path: "color", label: "Colour", widget: "color" },
|
||||||
|
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Region",
|
||||||
|
when: { path: "kind", value: "region" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "region.scope",
|
||||||
|
label: "Scope",
|
||||||
|
widget: "select",
|
||||||
|
options: ["domestic", "international", "virtual"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ path: "region.map_note", label: "Map note", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Chapter",
|
||||||
|
when: { path: "kind", value: "chapter" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "chapter.region_id",
|
||||||
|
label: "Region",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "regions",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
},
|
||||||
|
{ path: "chapter.meets", label: "Meets", help: "2nd Sundays, 6:00pm" },
|
||||||
|
{ path: "chapter.started", label: "Started", help: "Since 2021" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ legend: "Place", fields: PLACE_FIELDS },
|
||||||
|
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "region_areas",
|
||||||
|
label: "Map areas",
|
||||||
|
when: { path: "kind", value: "region" },
|
||||||
|
addLabel: "Add area",
|
||||||
|
title: (row) => row.area_code || "New area",
|
||||||
|
blank: { area_code: "", share: 1 },
|
||||||
|
fields: [
|
||||||
|
{ path: "area_code", label: "Area code", required: true, help: "WA, CA, CANADA" },
|
||||||
|
{ path: "share", label: "Share", widget: "number", help: "1 for the whole tile, 0.5 for half" },
|
||||||
|
{
|
||||||
|
path: "edge",
|
||||||
|
label: "Edge",
|
||||||
|
widget: "select",
|
||||||
|
options: ["top", "bottom"],
|
||||||
|
blankLabel: "— whole tile —",
|
||||||
|
},
|
||||||
|
{ path: "note", label: "Note", help: "north, Salt Lake City area" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
linksChild,
|
||||||
|
blocksChild,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Events ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const events = {
|
||||||
|
key: "events",
|
||||||
|
label: "Events",
|
||||||
|
singular: "event",
|
||||||
|
idLabel: "Slug",
|
||||||
|
slugFrom: "title",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "Title", primary: true },
|
||||||
|
{ key: "section_id", label: "Section" },
|
||||||
|
{ key: "date_label", label: "Dates" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
|
||||||
|
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
|
||||||
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Identity",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "section_id",
|
||||||
|
label: "Section",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "event_sections",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "host_org_id",
|
||||||
|
label: "Host",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
help: "Supplies the logo and colour when this event sets neither",
|
||||||
|
},
|
||||||
|
{ path: "title", label: "Title", required: true },
|
||||||
|
{ path: "theme", label: "Theme" },
|
||||||
|
{ path: "tagline", label: "Tagline", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "When",
|
||||||
|
fields: [
|
||||||
|
{ path: "starts_on", label: "Starts", widget: "date" },
|
||||||
|
{ path: "ends_on", label: "Ends", widget: "date" },
|
||||||
|
{
|
||||||
|
path: "date_label",
|
||||||
|
label: "Date label",
|
||||||
|
help: "What the card shows — 'March/April 2026' is fine here",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "status",
|
||||||
|
label: "Status",
|
||||||
|
widget: "select",
|
||||||
|
options: ["upcoming", "past", "cancelled"],
|
||||||
|
blankLabel: "— derive from end date —",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ legend: "Where", fields: PLACE_FIELDS },
|
||||||
|
{
|
||||||
|
legend: "Appearance",
|
||||||
|
fields: [
|
||||||
|
{ path: "event_logo", label: "Event logo", help: "Filename in public/event-logos/" },
|
||||||
|
{ path: "org_logo", label: "Org logo override" },
|
||||||
|
{ path: "color", label: "Colour", widget: "color" },
|
||||||
|
{ path: "gradient", label: "Gradient", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "event_people",
|
||||||
|
label: "People at this event",
|
||||||
|
addLabel: "Add person",
|
||||||
|
title: (row, options) =>
|
||||||
|
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
|
||||||
|
blank: { person_id: "", role: "speaker", is_public: 1 },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "person_id",
|
||||||
|
label: "Person",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "people",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "role",
|
||||||
|
label: "Role",
|
||||||
|
widget: "select",
|
||||||
|
options: [
|
||||||
|
"speaker",
|
||||||
|
"leader",
|
||||||
|
"facilitator",
|
||||||
|
"host",
|
||||||
|
"musician",
|
||||||
|
"volunteer",
|
||||||
|
"attendee",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "title", label: "Title", help: "Keynote Speaker" },
|
||||||
|
{ path: "is_public", label: "Show on the site", widget: "checkbox" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
linksChild,
|
||||||
|
blocksChild,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── People ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const people = {
|
||||||
|
key: "people",
|
||||||
|
label: "People",
|
||||||
|
singular: "person",
|
||||||
|
idLabel: "Slug",
|
||||||
|
slugFrom: "display_name",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "display_name", label: "Name", primary: true },
|
||||||
|
{ key: "tagline", label: "Tagline" },
|
||||||
|
{ key: "locality", label: "City" },
|
||||||
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Identity",
|
||||||
|
fields: [
|
||||||
|
{ path: "display_name", label: "Display name", required: true },
|
||||||
|
{ path: "sort_name", label: "Sort name", help: "Doe, Jane" },
|
||||||
|
{ path: "pronouns", label: "Pronouns" },
|
||||||
|
{ path: "tagline", label: "Tagline", full: true },
|
||||||
|
{ path: "photo", label: "Photo", help: "Filename in public/people/" },
|
||||||
|
{
|
||||||
|
path: "primary_org_id",
|
||||||
|
label: "Home organization",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
help: "Optional. Shown above their bio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "bio",
|
||||||
|
label: "Bio",
|
||||||
|
widget: "textarea",
|
||||||
|
full: true,
|
||||||
|
help: "Leave a blank line between paragraphs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Public contact",
|
||||||
|
fields: [
|
||||||
|
{ path: "public_email", label: "Email", help: "Printed on the site" },
|
||||||
|
{ path: "public_phone", label: "Phone" },
|
||||||
|
{ path: "locality", label: "City" },
|
||||||
|
{ path: "state_code", label: "State" },
|
||||||
|
{ path: "country", label: "Country" },
|
||||||
|
{ path: "location_label", label: "Display location" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Private",
|
||||||
|
note: "Never sent to the public site. Only admins see this.",
|
||||||
|
fields: [
|
||||||
|
{ path: "private.birth_date", label: "Birth date", widget: "date" },
|
||||||
|
{ path: "private.private_email", label: "Email" },
|
||||||
|
{ path: "private.private_phone", label: "Phone" },
|
||||||
|
{ path: "private.address", label: "Address", full: true },
|
||||||
|
{ path: "private.notes", label: "Notes", widget: "textarea", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Publishing",
|
||||||
|
note: "Unpublished people are invisible everywhere, including as chapter leads.",
|
||||||
|
fields: PUBLISH_FIELDS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "affiliations",
|
||||||
|
label: "Roles and organizations",
|
||||||
|
addLabel: "Add role",
|
||||||
|
note:
|
||||||
|
"Where someone appears within a team is set on that team's page, " +
|
||||||
|
"not by the order of this list.",
|
||||||
|
title: (row, options) =>
|
||||||
|
[row.title, options?.organizations?.find((o) => o.id === row.org_id)?.label]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "New role",
|
||||||
|
blank: { org_id: "", role: "member", is_public: 1, is_owner: 0 },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "org_id",
|
||||||
|
label: "Organization",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "team_id",
|
||||||
|
label: "Team",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "teams",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
// A team belongs to an org; offering one from another
|
||||||
|
// org would fail the composite foreign key on save.
|
||||||
|
filterBy: (option, row) => option.org_id === row.org_id,
|
||||||
|
help: "Only teams of the chosen organization",
|
||||||
|
},
|
||||||
|
...AFFILIATION_ROLE_FIELDS,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "person_awards",
|
||||||
|
label: "Awards",
|
||||||
|
addLabel: "Add award",
|
||||||
|
title: (row, options) =>
|
||||||
|
options?.awards?.find((a) => a.id === row.award_id)?.label ?? "New award",
|
||||||
|
blank: { award_id: "", is_public: 1 },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "award_id",
|
||||||
|
label: "Award",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "awards",
|
||||||
|
required: true,
|
||||||
|
help: "Labelled with the organization that gives it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "event_id",
|
||||||
|
label: "Presented at",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "events",
|
||||||
|
blankLabel: "— none —",
|
||||||
|
},
|
||||||
|
{ path: "awarded_on", label: "Date", widget: "date" },
|
||||||
|
{ path: "citation", label: "Citation", widget: "textarea", full: true },
|
||||||
|
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
linksChild,
|
||||||
|
blocksChild,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Teams ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const teams = {
|
||||||
|
key: "teams",
|
||||||
|
label: "Teams",
|
||||||
|
singular: "team",
|
||||||
|
idLabel: "Slug",
|
||||||
|
// teams.id is a global primary key, not scoped to the org, so
|
||||||
|
// 'board' can only exist once across the whole site. Composing
|
||||||
|
// the slug from both fields is what keeps NGU's board and the
|
||||||
|
// Northwest's board from colliding.
|
||||||
|
slugFrom: ["org_id", "name"],
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name", primary: true },
|
||||||
|
{ key: "org_id", label: "Organization" },
|
||||||
|
{ key: "tagline", label: "Tagline" },
|
||||||
|
{ key: "is_published", label: "Live", widget: "bool" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: "org_id", label: "Organization", optionsFrom: "organizations" },
|
||||||
|
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Identity",
|
||||||
|
note:
|
||||||
|
"Pick the organization first: the slug is built from it, " +
|
||||||
|
"and it can't be changed once anyone is filed under this team.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "org_id",
|
||||||
|
label: "Organization",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
required: true,
|
||||||
|
help: "Who this team belongs to",
|
||||||
|
},
|
||||||
|
{ path: "name", label: "Name", required: true, help: "Board, Leadership Team" },
|
||||||
|
{ path: "tagline", label: "Tagline", full: true },
|
||||||
|
{ path: "color", label: "Colour", widget: "color" },
|
||||||
|
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Members",
|
||||||
|
addLabel: "Add member",
|
||||||
|
note:
|
||||||
|
"This order is the order they appear on the site. Owners are " +
|
||||||
|
"still listed first, in this order among themselves.",
|
||||||
|
title: (row, options) =>
|
||||||
|
[options?.people?.find((p) => p.id === row.person_id)?.label, row.title]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "New member",
|
||||||
|
blank: { person_id: "", role: "member", is_public: 1, is_owner: 0 },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "person_id",
|
||||||
|
label: "Person",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "people",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
...AFFILIATION_ROLE_FIELDS,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
linksChild,
|
||||||
|
blocksChild,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Awards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const awards = {
|
||||||
|
key: "awards",
|
||||||
|
label: "Awards",
|
||||||
|
singular: "award",
|
||||||
|
idLabel: "Slug",
|
||||||
|
slugFrom: "name",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name", primary: true },
|
||||||
|
{ key: "org_id", label: "Awarded by" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
{ key: "sort_order", label: "Order" },
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Identity",
|
||||||
|
note:
|
||||||
|
"An award exists whether or not anyone has received it. " +
|
||||||
|
"Who received it is edited on the person.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "org_id",
|
||||||
|
label: "Awarded by",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "organizations",
|
||||||
|
blankLabel: "— unattributed —",
|
||||||
|
help: "The organization that gives this award",
|
||||||
|
},
|
||||||
|
{ path: "name", label: "Name", required: true },
|
||||||
|
{ path: "description", label: "Description", widget: "textarea", full: true },
|
||||||
|
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||||
|
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
|
||||||
|
|
||||||
|
export function slugify(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/[\s_]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 64);
|
||||||
|
}
|
||||||
30
src/lib/adminTitle.tsx
Normal file
30
src/lib/adminTitle.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN TITLE
|
||||||
|
|
||||||
|
The layout knows which section you're in; only the page knows
|
||||||
|
which record is on screen. Rather than have both write
|
||||||
|
document.title and race — child effects run before parent
|
||||||
|
effects, so the layout would win and the record name would
|
||||||
|
never survive — the page publishes a string and the layout is
|
||||||
|
the single writer.
|
||||||
|
|
||||||
|
Null is the normal state. A page that publishes nothing, or one
|
||||||
|
still loading, simply leaves the section title showing.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
export const AdminTitleContext = createContext(null);
|
||||||
|
|
||||||
|
/* Publish the name of whatever this page is showing. Clears on
|
||||||
|
unmount, so navigating away can't leave a stale record name in
|
||||||
|
the tab. */
|
||||||
|
export function useAdminDetail(name) {
|
||||||
|
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setDetail) return undefined;
|
||||||
|
setDetail(name || null);
|
||||||
|
return () => setDetail(null);
|
||||||
|
}, [setDetail, name]);
|
||||||
|
}
|
||||||
|
|
@ -100,3 +100,15 @@ export function post(path, data) {
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patch(path, data) {
|
||||||
|
return request(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function del(path) {
|
||||||
|
return request(path, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
|
||||||
98
src/lib/auth.tsx
Normal file
98
src/lib/auth.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
AUTH CONTEXT
|
||||||
|
|
||||||
|
One fetch of /api/auth/me at mount decides whether anyone is
|
||||||
|
signed in. There's no token to store: the session cookie is
|
||||||
|
HttpOnly, so this code can't read it and neither can anything
|
||||||
|
injected into the page. "Am I signed in" is always a question
|
||||||
|
for the server.
|
||||||
|
|
||||||
|
RequireAuth guards routes. Worth being clear about what that
|
||||||
|
does and doesn't do: it hides the interface, not the data. The
|
||||||
|
protection is the 401 from /api/admin — this only stops someone
|
||||||
|
staring at an empty table.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get, post, ApiError } from "./api.js";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
get("/auth/me", { ttl: 0 })
|
||||||
|
.then((data) => {
|
||||||
|
if (!ignore) setUser(data.user);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!ignore) setUser(null); // 401 is the normal case
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!ignore) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
const data = await post("/auth/login", { email, password });
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await post("/auth/logout", {});
|
||||||
|
} finally {
|
||||||
|
// Whatever the server said, this browser is done.
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const value = useContext(AuthContext);
|
||||||
|
if (!value) throw new Error("useAuth used outside AuthProvider");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signals a session that ended while the page was open — the
|
||||||
|
admin pages call this when a request comes back 401. */
|
||||||
|
export function isUnauthorized(error) {
|
||||||
|
return error instanceof ApiError && error.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequireAuth() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center text-[#4a6b72]">
|
||||||
|
Checking your session…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// `from` lets the login page send them back where they aimed.
|
||||||
|
return <Navigate to="/admin/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,6 @@
|
||||||
import PageShell from "../components/PageShell.tsx";
|
import PageShell from "../components/PageShell.tsx";
|
||||||
import PeopleTiles from "../components/PeopleTiles.tsx";
|
import PeopleTiles from "../components/PeopleTiles.tsx";
|
||||||
|
|
||||||
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
|
|
||||||
// the component only cares about the shape, not where it came from.
|
|
||||||
|
|
||||||
export const leadershipGroups = [
|
|
||||||
{
|
|
||||||
id: "director",
|
|
||||||
label: "Retreat Director",
|
|
||||||
accent: "#138ba0",
|
|
||||||
people: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "John Doe",
|
|
||||||
title: "Retreat Director",
|
|
||||||
photo: "/people/jordan-ellis.jpg",
|
|
||||||
pronouns: "he/him",
|
|
||||||
birthdate: "1994-03-08",
|
|
||||||
org: { name: "Unity of Des Moines", href: "https://example.org" },
|
|
||||||
bio: [
|
|
||||||
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
|
|
||||||
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team",
|
|
||||||
label: "Retreat Team",
|
|
||||||
people: [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Priya Raman",
|
|
||||||
title: "Events Lead",
|
|
||||||
photo: "/people/priya-raman.jpg",
|
|
||||||
pronouns: "she/her",
|
|
||||||
age: 27,
|
|
||||||
org: "Unity Chicago",
|
|
||||||
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Sam Okafor",
|
|
||||||
title: "Communications",
|
|
||||||
pronouns: "they/them",
|
|
||||||
age: 24,
|
|
||||||
org: "Unity of Milwaukee",
|
|
||||||
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const board = [
|
|
||||||
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
|
|
||||||
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
|
|
||||||
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
id: "board-staff",
|
id: "board-staff",
|
||||||
|
|
@ -67,7 +10,7 @@ const SECTIONS = [
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
content: (
|
content: (
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
<PeopleTiles size="sm" people={board} overflow="scroll" />
|
<PeopleTiles size="lg" teams={{ id: "ngu-board",}} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -79,7 +22,7 @@ const SECTIONS = [
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
content: (
|
content: (
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
<PeopleTiles size="lg" groups={leadershipGroups} />
|
<PeopleTiles size="lg" teams="ngu-retreat-team" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
315
src/pages/admin/AdminFeedback.tsx
Normal file
315
src/pages/admin/AdminFeedback.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN — FEEDBACK TRIAGE
|
||||||
|
|
||||||
|
Reads /api/admin/feedback, writes status and notes back through
|
||||||
|
PATCH. Deliberately a flat list rather than a table: the message
|
||||||
|
is the content, and messages don't fit in a cell.
|
||||||
|
|
||||||
|
Every read passes ttl: 0. The api cache exists for public
|
||||||
|
content that changes weekly; a triage queue two people are
|
||||||
|
working at the same time is the opposite case.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get, patch, ApiError } from "../../lib/api.js";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||||
|
|
||||||
|
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
new: "bg-[#138ba0] text-white",
|
||||||
|
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||||
|
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||||
|
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
|
||||||
|
spam: "bg-[#fdf3f2] text-[#b3261e]",
|
||||||
|
};
|
||||||
|
|
||||||
|
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||||
|
// won't parse without the T and the Z.
|
||||||
|
function formatDate(value) {
|
||||||
|
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationOf(row) {
|
||||||
|
if (!row.page_path) return "Not page-specific";
|
||||||
|
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── One submission ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function FeedbackCard({ row, onChange, canWrite }) {
|
||||||
|
const [note, setNote] = useState(row.admin_note ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const noteDirty = note !== (row.admin_note ?? "");
|
||||||
|
|
||||||
|
async function save(changes) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||||
|
onChange(data.feedback);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
<span className="font-semibold text-[#26454c]">
|
||||||
|
{feedbackTypeLabel(row.feedback_type)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
STATUS_STYLE[row.status] ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#4a6b72]">{locationOf(row)}</span>
|
||||||
|
<span className="ml-auto text-xs text-[#4a6b72]">
|
||||||
|
#{row.id} · {formatDate(row.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-[#4a6b72]">
|
||||||
|
{row.name || row.email ? (
|
||||||
|
<>
|
||||||
|
{row.name && <span>{row.name}</span>}
|
||||||
|
{row.name && row.email && " · "}
|
||||||
|
{row.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
|
||||||
|
className="text-[#138ba0] underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{row.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Sent anonymously</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`status-${row.id}`}
|
||||||
|
className="text-sm font-medium text-[#26454c]"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`status-${row.id}`}
|
||||||
|
value={row.status}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => save({ status: e.target.value })}
|
||||||
|
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={note}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="Internal note — who's handling it, what was done"
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||||
|
/>
|
||||||
|
{noteDirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => save({ admin_note: note })}
|
||||||
|
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Save note"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The page ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function AdminFeedback() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState("new");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [search, setSearch] = useState(""); // applied, not typed
|
||||||
|
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [counts, setCounts] = useState({});
|
||||||
|
const [cursor, setCursor] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const canWrite = user?.role === "admin";
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (before = null) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status !== "all") params.set("status", status);
|
||||||
|
if (search) params.set("q", search);
|
||||||
|
if (before) params.set("before", String(before));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||||
|
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||||
|
setCounts(data.counts);
|
||||||
|
setCursor(data.nextCursor);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) {
|
||||||
|
// Session expired while the page was open.
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[status, search, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function replaceRow(updated) {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev
|
||||||
|
.map((row) => (row.id === updated.id ? updated : row))
|
||||||
|
// A row that no longer matches the filter drops out, so
|
||||||
|
// marking something 'read' clears it from the 'new' queue.
|
||||||
|
.filter((row) => status === "all" || row.status === status),
|
||||||
|
);
|
||||||
|
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "all", label: "All" },
|
||||||
|
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||||
|
{canWrite
|
||||||
|
? "Everything submitted through the site form."
|
||||||
|
: "Read-only: your account can't change statuses or notes."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus(tab.id)}
|
||||||
|
className={
|
||||||
|
"rounded-full px-4 py-1.5 text-sm transition-colors " +
|
||||||
|
(status === tab.id
|
||||||
|
? "bg-[#138ba0] font-semibold text-white"
|
||||||
|
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.count ? ` (${tab.count})` : ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder="Search messages"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") setSearch(query.trim());
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch(query.trim())}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && rows.length === 0 && !error && (
|
||||||
|
<p className="mt-10 text-[#4a6b72]">
|
||||||
|
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<FeedbackCard
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onChange={replaceRow}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
|
||||||
|
|
||||||
|
{cursor && !loading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(cursor)}
|
||||||
|
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Load older
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/pages/admin/AdminLayout.tsx
Normal file
160
src/pages/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN LAYOUT
|
||||||
|
|
||||||
|
Bare on purpose. No PageShell, no announcement banner, no
|
||||||
|
footer site map — none of that belongs around a staff tool, and
|
||||||
|
/admin should never appear in navConfig.
|
||||||
|
|
||||||
|
The nav is two tiers, the same shape as the public header: a
|
||||||
|
primary row of the things you'd go looking for, and a subnav of
|
||||||
|
whatever sits under the one you're in. Teams and Awards live
|
||||||
|
under Organizations because that's where they belong
|
||||||
|
conceptually — a team is part of an org, an award is given by
|
||||||
|
one — even though each is its own table and its own page.
|
||||||
|
|
||||||
|
NAV is the single source for the rows, the document title and
|
||||||
|
which tab lights up. Adding an entity is an entry here plus a
|
||||||
|
descriptor; there's no second list to keep in step.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
||||||
|
|
||||||
|
const SITE_TITLE = "NGU Admin CMS";
|
||||||
|
|
||||||
|
// A group with no `to` of its own opens its first child, so
|
||||||
|
// clicking the word Forms goes somewhere rather than nowhere.
|
||||||
|
const NAV = [
|
||||||
|
{ to: "/admin/events", label: "Events" },
|
||||||
|
{
|
||||||
|
to: "/admin/organizations",
|
||||||
|
label: "Organizations",
|
||||||
|
children: [
|
||||||
|
{ to: "/admin/organizations", label: "All organizations" },
|
||||||
|
{ to: "/admin/teams", label: "Teams" },
|
||||||
|
{ to: "/admin/awards", label: "Awards" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ to: "/admin/people", label: "People" },
|
||||||
|
{
|
||||||
|
label: "Forms",
|
||||||
|
separated: true,
|
||||||
|
children: [{ to: "/admin/feedback", label: "Website feedback" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// A tab owns its own page and everything below it, so editing
|
||||||
|
// /admin/teams/ngu-board keeps Teams lit.
|
||||||
|
const matches = (pathname, to) =>
|
||||||
|
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||||
|
|
||||||
|
const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const active = NAV.find(
|
||||||
|
(item) =>
|
||||||
|
matches(pathname, item.to) ||
|
||||||
|
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeChild = (active?.children ?? []).find((child) =>
|
||||||
|
matches(pathname, child.to),
|
||||||
|
);
|
||||||
|
|
||||||
|
// What the page below has published about itself — a record
|
||||||
|
// name, or null on a list. setDetail is stable so publishing
|
||||||
|
// can't loop.
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const stableSet = useCallback((value) => setDetail(value), []);
|
||||||
|
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const section = activeChild?.label ?? active?.label;
|
||||||
|
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
|
||||||
|
}, [active, activeChild, detail]);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnav = active?.children ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f6fbfc]">
|
||||||
|
<header className="border-b border-[#138ba0]/20 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
|
||||||
|
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<div key={item.label} className="flex items-center gap-6">
|
||||||
|
{item.separated && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-px bg-[#4a6b72]/25"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NavLink
|
||||||
|
to={target(item)}
|
||||||
|
className={
|
||||||
|
item === active
|
||||||
|
? "font-semibold text-[#138ba0]"
|
||||||
|
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
|
||||||
|
<span>{user?.name || user?.email}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-full border border-[#4a6b72]/30 px-4 py-1.5 font-medium transition-colors hover:bg-[#eef9fb] hover:text-[#138ba0]"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subnav. Only drawn where there is something to draw, so
|
||||||
|
Events and People don't get an empty grey strip. */}
|
||||||
|
{subnav.length > 0 && (
|
||||||
|
<div className="border-t border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap gap-6 px-6 py-2.5 text-sm">
|
||||||
|
{subnav.map((child) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.to}
|
||||||
|
to={child.to}
|
||||||
|
className={
|
||||||
|
child === activeChild
|
||||||
|
? "font-semibold text-[#138ba0]"
|
||||||
|
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||||
|
<AdminTitleContext.Provider value={titleContext}>
|
||||||
|
<Outlet />
|
||||||
|
</AdminTitleContext.Provider>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/pages/admin/AdminLogin.tsx
Normal file
142
src/pages/admin/AdminLogin.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN LOGIN
|
||||||
|
|
||||||
|
Deliberately outside PageShell and the site nav. This isn't a
|
||||||
|
page of the website; it's the door to the back office, and it
|
||||||
|
shouldn't carry a banner, a subnav, or a footer site map.
|
||||||
|
|
||||||
|
The Google button is stubbed and disabled until the OAuth
|
||||||
|
routes exist. It's here so the layout doesn't change when it
|
||||||
|
starts working.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { ApiError } from "../../lib/api.js";
|
||||||
|
|
||||||
|
const GOOGLE_ENABLED = false;
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||||
|
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||||
|
|
||||||
|
export default function AdminLogin() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const destination = location.state?.from?.pathname ?? "/admin/feedback";
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password);
|
||||||
|
navigate(destination, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: "Couldn't reach the server. Try again in a moment.",
|
||||||
|
);
|
||||||
|
setPassword("");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#eef9fb] px-6 py-16">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="text-3xl font-bold text-[#138ba0]">NGU admin</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||||
|
Sign in to read and triage site feedback.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
className="mt-8 rounded-2xl border border-[#138ba0]/20 bg-white p-6"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="admin-email"
|
||||||
|
className="block text-sm font-medium text-[#26454c]"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className={`mt-2 ${fieldClass}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="admin-password"
|
||||||
|
className="mt-5 block text-sm font-medium text-[#26454c]"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={`mt-2 ${fieldClass}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-5 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !email || !password}
|
||||||
|
className="mt-6 w-full rounded-full bg-[#138ba0] px-6 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25"
|
||||||
|
>
|
||||||
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{GOOGLE_ENABLED && (
|
||||||
|
<>
|
||||||
|
<div className="my-6 flex items-center gap-3 text-xs text-[#4a6b72]">
|
||||||
|
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||||
|
or
|
||||||
|
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/auth/google"
|
||||||
|
className="block rounded-full border border-[#4a6b72]/30 px-6 py-3 text-center font-semibold text-[#26454c] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-[#4a6b72]">
|
||||||
|
Accounts are created on the server. Ask whoever runs the box.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
src/pages/admin/EntityEdit.tsx
Normal file
422
src/pages/admin/EntityEdit.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN — ENTITY EDIT
|
||||||
|
|
||||||
|
Create and edit for every entity in the manifest. The form holds
|
||||||
|
the whole nested object — parent row, side tables, child
|
||||||
|
collections — and PATCH sends the lot. The server replaces
|
||||||
|
children wholesale, so what you see here is exactly what will
|
||||||
|
exist afterwards.
|
||||||
|
|
||||||
|
Nothing is written until Save, which is what makes removing a
|
||||||
|
repeater row safe: leaving without saving undoes it. That only
|
||||||
|
holds if leaving is hard to do by accident, hence the dirty
|
||||||
|
tracking and the two guards below.
|
||||||
|
|
||||||
|
A field the form never sets is left out of the payload entirely,
|
||||||
|
and the server lets the column's own DEFAULT apply. So a blank
|
||||||
|
new-record form is deliberate, not lazy — writing "" into every
|
||||||
|
field is what used to turn a default into a constraint failure.
|
||||||
|
|
||||||
|
updated_at rides along untouched. If someone else saved while
|
||||||
|
this page was open the server answers 409 rather than letting
|
||||||
|
one of you quietly overwrite the other. Entities with no
|
||||||
|
updated_at column simply never get one, and the 409 path stays
|
||||||
|
dormant for them.
|
||||||
|
|
||||||
|
slugFrom may name one field or several. Most ids are unique
|
||||||
|
because the name is: two organizations aren't both called
|
||||||
|
Northwest. Team ids are the exception — teams.id is a global
|
||||||
|
primary key, so every org's 'Board' would collide — which is
|
||||||
|
why the array form exists and teams uses it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||||
|
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||||
|
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||||
|
|
||||||
|
/* A foreign key refusing to budge is the most common way a save or
|
||||||
|
delete fails here, and SQLite's own wording explains nothing to
|
||||||
|
whoever is filling in the form. */
|
||||||
|
function friendly(message, singular) {
|
||||||
|
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
||||||
|
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntityEdit() {
|
||||||
|
const { entity: entityKey, id } = useParams();
|
||||||
|
const manifest = ADMIN_ENTITIES[entityKey];
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const isNew = id === "new";
|
||||||
|
const canWrite = user?.role === "admin";
|
||||||
|
|
||||||
|
// Hoisted above the loading guards: the title hook below is a
|
||||||
|
// hook, so it can't sit after an early return, and it needs the
|
||||||
|
// same paths the heading uses.
|
||||||
|
const slugPaths = Array.isArray(manifest?.slugFrom)
|
||||||
|
? manifest.slugFrom
|
||||||
|
: [manifest?.slugFrom].filter(Boolean);
|
||||||
|
|
||||||
|
// Everything before the last path is a qualifier: a fact about
|
||||||
|
// another field rather than something to type. It renders as
|
||||||
|
// fixed text inside the slug box, and the editable part is only
|
||||||
|
// what follows it.
|
||||||
|
const qualifierPaths = slugPaths.slice(0, -1);
|
||||||
|
|
||||||
|
// Empty until every qualifier is chosen, because half a prefix
|
||||||
|
// would be saved into an id that then never matches.
|
||||||
|
const prefixOf = (source) => {
|
||||||
|
if (qualifierPaths.length === 0) return "";
|
||||||
|
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||||
|
if (parts.some((part) => !part)) return "";
|
||||||
|
return `${slugify(parts.join(" "))}-`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What to show greyed out before then: org_id becomes "org-".
|
||||||
|
const prefixHint = qualifierPaths.length
|
||||||
|
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const tailOf = (source) => {
|
||||||
|
const prefix = prefixOf(source);
|
||||||
|
const value = source?.id ?? "";
|
||||||
|
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The heading wants the specific half, not the qualifier: a team
|
||||||
|
// page reads "Board", not "northwest Board".
|
||||||
|
const headingPath = slugPaths[slugPaths.length - 1];
|
||||||
|
|
||||||
|
const [form, setForm] = useState(null);
|
||||||
|
const [options, setOptions] = useState({});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [message, setMessage] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
||||||
|
// The last state the server confirmed. Everything else compares
|
||||||
|
// against this to decide whether there's anything to lose.
|
||||||
|
const baseline = useRef(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!manifest) return;
|
||||||
|
setLoading(true);
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||||
|
setOptions(opts.options);
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
// Blank children arrays matter: an absent key means "don't
|
||||||
|
// touch", which is wrong for a row that doesn't exist yet.
|
||||||
|
// The parent's own fields stay absent on purpose so the
|
||||||
|
// server's column defaults apply to whatever isn't filled in.
|
||||||
|
const blank = { id: "" };
|
||||||
|
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||||
|
setForm(blank);
|
||||||
|
baseline.current = JSON.stringify(blank);
|
||||||
|
} else {
|
||||||
|
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||||
|
setForm(data.row);
|
||||||
|
baseline.current = JSON.stringify(data.row);
|
||||||
|
}
|
||||||
|
setMessage(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setMessage({
|
||||||
|
tone: "error",
|
||||||
|
text: err instanceof ApiError ? err.message : "Couldn't load that.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [manifest, id, isNew, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The tab says what's on screen; the layout adds the section and
|
||||||
|
// the site name. Null while loading, so it reads "Teams | NGU
|
||||||
|
// Admin CMS" for the half-second before the record arrives
|
||||||
|
// rather than flashing a slug.
|
||||||
|
useAdminDetail(
|
||||||
|
!manifest
|
||||||
|
? null
|
||||||
|
: isNew
|
||||||
|
? `New ${manifest.singular}`
|
||||||
|
: form
|
||||||
|
? getPath(form, headingPath) || form.id
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Closing the tab or hitting the browser back button skips React
|
||||||
|
// Router entirely, so the only hook available is this one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return undefined;
|
||||||
|
const warn = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", warn);
|
||||||
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
|
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||||
|
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
|
||||||
|
|
||||||
|
/* ── Heading ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const heading = getPath(form, headingPath) || form.id;
|
||||||
|
|
||||||
|
const children = manifest.children ?? [];
|
||||||
|
|
||||||
|
/* ── Actions ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const leave = (to) => {
|
||||||
|
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||||
|
navigate(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
const change = (path, value) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
let next = setPath(prev, path, value);
|
||||||
|
// Recompose the id whenever one of its sources moves. The
|
||||||
|
// prefix always follows the organization — picking a
|
||||||
|
// different one has to change the slug, or it would claim a
|
||||||
|
// team belongs somewhere it doesn't. The tail only follows
|
||||||
|
// the name until someone types over it.
|
||||||
|
if (isNew && slugPaths.includes(path)) {
|
||||||
|
const tail = slugTouched
|
||||||
|
? tailOf(prev)
|
||||||
|
: slugify(getPath(next, headingPath) ?? "");
|
||||||
|
next = { ...next, id: `${prefixOf(next)}${tail}` };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Clear this field's error as soon as it's touched; leaving a
|
||||||
|
// stale red outline on a field the user just fixed reads as a
|
||||||
|
// save that didn't take.
|
||||||
|
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setErrors({});
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const data = isNew
|
||||||
|
? await post(`/admin/${manifest.key}`, form)
|
||||||
|
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||||
|
|
||||||
|
setForm(data.row);
|
||||||
|
baseline.current = JSON.stringify(data.row);
|
||||||
|
setMessage({ tone: "ok", text: "Saved." });
|
||||||
|
|
||||||
|
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// 409 means the updated_at we're holding is stale. Every
|
||||||
|
// further save will fail the same way until the page is
|
||||||
|
// reloaded, so offer that rather than just saying no.
|
||||||
|
if (err.status === 409) {
|
||||||
|
setMessage({ tone: "error", text: err.message, recover: "reload" });
|
||||||
|
} else {
|
||||||
|
setErrors(err.fields ?? {});
|
||||||
|
setMessage({
|
||||||
|
tone: "error",
|
||||||
|
text: err.fields
|
||||||
|
? "Some fields need attention."
|
||||||
|
: friendly(err.message, manifest.singular),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMessage({ tone: "error", text: "Couldn't reach the server." });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await del(`/admin/${manifest.key}/${id}`);
|
||||||
|
baseline.current = JSON.stringify(form); // nothing left to warn about
|
||||||
|
navigate(`/admin/${manifest.key}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({
|
||||||
|
tone: "error",
|
||||||
|
text:
|
||||||
|
err instanceof ApiError
|
||||||
|
? friendly(err.message, manifest.singular)
|
||||||
|
: "Couldn't delete that.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-24">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => leave(`/admin/${manifest.key}`)}
|
||||||
|
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
|
||||||
|
>
|
||||||
|
← {manifest.label}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
||||||
|
{isNew ? `New ${manifest.singular}` : heading}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<Field
|
||||||
|
field={{
|
||||||
|
path: "id",
|
||||||
|
label: manifest.idLabel,
|
||||||
|
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
|
||||||
|
prefixPending: isNew && !prefixOf(form),
|
||||||
|
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
|
||||||
|
readOnly: !isNew,
|
||||||
|
help: !isNew
|
||||||
|
? "Fixed once created — links and content blocks reference it."
|
||||||
|
: qualifierPaths.length
|
||||||
|
? "The prefix comes from the organization. Type the rest."
|
||||||
|
: "Lowercase, hyphens, no spaces. Can't be changed later.",
|
||||||
|
}}
|
||||||
|
value={isNew ? tailOf(form) : form.id}
|
||||||
|
error={errors.id}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSlugTouched(true);
|
||||||
|
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isNew && form.updated_at && (
|
||||||
|
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||||
|
Last saved {form.updated_at}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field groups */}
|
||||||
|
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
||||||
|
<section
|
||||||
|
key={group.legend}
|
||||||
|
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
|
||||||
|
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
|
||||||
|
<div className="mt-4">
|
||||||
|
<FieldGrid>
|
||||||
|
{group.fields.map((field) => (
|
||||||
|
<Field
|
||||||
|
key={field.path}
|
||||||
|
field={field}
|
||||||
|
value={getPath(form, field.path)}
|
||||||
|
options={options}
|
||||||
|
error={errors[field.path]}
|
||||||
|
onChange={(value) => change(field.path, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FieldGrid>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Child collections. Awards own none, so the panel would be
|
||||||
|
an empty white box — skip it rather than render it. */}
|
||||||
|
{children.some((child) => visible(child.when)) && (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
{children
|
||||||
|
.filter((child) => visible(child.when))
|
||||||
|
.map((child) => (
|
||||||
|
<Repeater
|
||||||
|
key={child.key}
|
||||||
|
spec={child}
|
||||||
|
rows={form[child.key]}
|
||||||
|
options={options}
|
||||||
|
errors={errors}
|
||||||
|
errorPrefix={`${child.key}.`}
|
||||||
|
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sticky action bar */}
|
||||||
|
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
|
||||||
|
{canWrite ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-[#4a6b72]">
|
||||||
|
Read-only: your account can't save changes.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dirty && !saving && (
|
||||||
|
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
className={`flex items-center gap-2 text-sm ${
|
||||||
|
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
{message.recover === "reload" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/pages/admin/EntityList.tsx
Normal file
181
src/pages/admin/EntityList.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN — ENTITY LIST
|
||||||
|
|
||||||
|
One component for organizations, events and people. The :entity
|
||||||
|
route param picks the manifest; nothing here knows what a
|
||||||
|
chapter or a retreat is.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get, ApiError } from "../../lib/api.js";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||||
|
|
||||||
|
export default function EntityList() {
|
||||||
|
const { entity: entityKey } = useParams();
|
||||||
|
const manifest = ADMIN_ENTITIES[entityKey];
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [options, setOptions] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
|
|
||||||
|
const canWrite = user?.role === "admin";
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!manifest) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [list, opts] = await Promise.all([
|
||||||
|
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||||
|
get("/admin/options", { ttl: 60_000 }),
|
||||||
|
]);
|
||||||
|
setRows(list.rows);
|
||||||
|
setOptions(opts.options);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setError(err instanceof ApiError ? err.message : "Couldn't reach the server.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [manifest, params, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setParam(key, value) {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
if (value) next.set(key, value);
|
||||||
|
else next.delete(key);
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(filter, value) {
|
||||||
|
if (filter.optionsFrom) {
|
||||||
|
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||||
|
}
|
||||||
|
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold text-[#138ba0]">{manifest.label}</h1>
|
||||||
|
{canWrite && (
|
||||||
|
<Link
|
||||||
|
to={`/admin/${manifest.key}/new`}
|
||||||
|
className="ml-auto rounded-full bg-[#138ba0] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183]"
|
||||||
|
>
|
||||||
|
New {manifest.singular}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-end gap-3">
|
||||||
|
{manifest.list.filters.map((filter) => (
|
||||||
|
<label key={filter.key} className="text-xs text-[#4a6b72]">
|
||||||
|
<span className="block">{filter.label}</span>
|
||||||
|
<select
|
||||||
|
value={params.get(filter.key) ?? ""}
|
||||||
|
onChange={(e) => setParam(filter.key, e.target.value)}
|
||||||
|
className="mt-1 rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{labelFor(filter).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder="Search"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setParam("q", query.trim())}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setParam("q", query.trim())}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] hover:border-[#138ba0]/50"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="mt-6 overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-[#4a6b72]/15 text-xs text-[#4a6b72]">
|
||||||
|
<tr>
|
||||||
|
{manifest.list.columns.map((column) => (
|
||||||
|
<th key={column.key} className="px-4 py-3 font-medium">
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-4 py-3 font-medium">Slug</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||||
|
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||||
|
>
|
||||||
|
{manifest.list.columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={`px-4 py-3 ${
|
||||||
|
column.primary ? "font-medium text-[#26454c]" : "text-[#4a6b72]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{column.widget === "bool"
|
||||||
|
? row[column.key]
|
||||||
|
? "Yes"
|
||||||
|
: "—"
|
||||||
|
: row[column.key] || "—"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-[#4a6b72]">Nothing matches.</p>
|
||||||
|
)}
|
||||||
|
{loading && <p className="px-4 py-8 text-center text-[#4a6b72]">Loading…</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { post, ApiError } from "../../lib/api.js";
|
import { post, ApiError } from "../../lib/api.js";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||||
|
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
||||||
|
|
||||||
const ACCENT = "#138ba0";
|
const ACCENT = "#138ba0";
|
||||||
const MUTED = "#4a6b72";
|
const MUTED = "#4a6b72";
|
||||||
|
|
@ -25,39 +26,6 @@ const SITE_WIDE = "site";
|
||||||
// Sentinel for "this page, but not one section of it".
|
// Sentinel for "this page, but not one section of it".
|
||||||
const WHOLE_PAGE = "";
|
const WHOLE_PAGE = "";
|
||||||
|
|
||||||
// Ids must match TYPES in server/src/routes/feedback.js.
|
|
||||||
const FEEDBACK_TYPES = [
|
|
||||||
{
|
|
||||||
id: "broken",
|
|
||||||
label: "Something's broken",
|
|
||||||
hint: "A link, image, or button that doesn't work",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "confusing",
|
|
||||||
label: "Hard to use",
|
|
||||||
hint: "Something you couldn't find or follow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "outdated",
|
|
||||||
label: "Wrong or missing info",
|
|
||||||
hint: "Old dates, typos, an event that isn't listed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "request",
|
|
||||||
label: "Feature request",
|
|
||||||
hint: "Something you'd like the site to do",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "praise",
|
|
||||||
label: "Kind words",
|
|
||||||
hint: "Tell us what's working well",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "other",
|
|
||||||
label: "Something else",
|
|
||||||
hint: "Anything that doesn't fit the boxes above",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ── Shared field chrome ─────────────────────────────────────── */
|
/* ── Shared field chrome ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue