Compare commits
30 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b10a263a90 | |||
|
|
625329fa7c | ||
|
|
7bc71cef8d | ||
|
|
25e592bfea | ||
|
|
8790f861fe | ||
| 26d6844bc8 | |||
|
|
370fce6e9a | ||
| cbf440bed9 | |||
|
|
22d5328885 | ||
|
|
5cedc68fd7 | ||
| 4618ad8e67 | |||
|
|
2e125b4629 | ||
|
|
641ab167b0 | ||
|
|
6a69084de2 | ||
| b4b013209b | |||
|
|
6ec2fb240a | ||
| 300c9b74f0 | |||
|
|
5ed7994a64 | ||
| aebc1c302b | |||
|
|
1b2af2bfbd | ||
| 9b91a9aa78 | |||
|
|
2428f4412a | ||
| 5286cae9ca | |||
|
|
32d04b63e9 | ||
|
|
75c40cfb73 | ||
| 809c3c56c5 | |||
|
|
ea58c5ce3f | ||
|
|
bce1a3fcf6 | ||
|
|
1d84400aef | ||
|
|
1f0aa3078f |
105 changed files with 17737 additions and 2589 deletions
78
CLAUDE.md
Normal file
78
CLAUDE.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
# NGU-Web
|
||||||
|
|
||||||
|
Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`)
|
||||||
|
- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`)
|
||||||
|
- Package manager: pnpm only (never npm or yarn)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
Frontend (repo root):
|
||||||
|
- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite
|
||||||
|
- `pnpm format`: oxfmt
|
||||||
|
- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script)
|
||||||
|
- There is no test suite.
|
||||||
|
|
||||||
|
Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`:
|
||||||
|
- `DB_PATH=./dev.db pnpm dev`: run with `node --watch`
|
||||||
|
- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server
|
||||||
|
- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created
|
||||||
|
|
||||||
|
In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port.
|
||||||
|
|
||||||
|
## Deployment (production)
|
||||||
|
- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api`
|
||||||
|
- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db`
|
||||||
|
- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service.
|
||||||
|
- Never run commands against the production server or database unless explicitly asked.
|
||||||
|
|
||||||
|
## Git workflow
|
||||||
|
- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`.
|
||||||
|
- Open pull requests with `tea`: `tea pr create --base main --head <branch> --title "..." --description "..."`
|
||||||
|
- Never commit directly to main. Create a branch per change, push it, open a PR.
|
||||||
|
- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked.
|
||||||
|
- `server/dev.db` and other `*.db` files are local only and never committed.
|
||||||
|
|
||||||
|
## How to work in this repo
|
||||||
|
- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions.
|
||||||
|
- Ask questions up front before implementing non-trivial features.
|
||||||
|
- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked.
|
||||||
|
- Fix root causes. No redirect shims or workarounds.
|
||||||
|
- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components.
|
||||||
|
- Name components for what they do, not what they currently filter.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise.
|
||||||
|
- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`.
|
||||||
|
- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite.
|
||||||
|
- `navConfig.ts` is the single source of truth for navigation, routes, and actions (header, footer, pages).
|
||||||
|
- `api.ts` is the shared caching client used by frontend data hooks.
|
||||||
|
- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `<Logo>` component hides itself on load error.
|
||||||
|
|
||||||
|
## Rules and gotchas
|
||||||
|
- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs.
|
||||||
|
- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere.
|
||||||
|
- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names.
|
||||||
|
- **Don't use `fallback: EMPTY` in api.ts hooks.** It silently returns empty arrays and hides server errors; let the error state surface.
|
||||||
|
|
||||||
|
## Admin CRUD engine
|
||||||
|
Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.ts` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code.
|
||||||
|
- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere.
|
||||||
|
- `reindex: false` prevents cross-entity sort order collisions.
|
||||||
|
- The `OMIT` sentinel distinguishes unsent fields from deliberate clears.
|
||||||
|
- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree.
|
||||||
|
- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
- `server/src/migrations/023_schema.sql` is the baseline: the whole schema, consolidated from the old 001–023. It only runs on an empty database; the runner refuses a database between v1 and v22. It's the place to read the schema, not to change it: a database that already exists never re-runs it.
|
||||||
|
- Changes go in new sequential files after it: `024_`, `025_`, ...
|
||||||
|
- The runner may drop statements after a `BEGIN...END` trigger body. Keep triggers last in a file, and put each `CREATE VIEW` before any trigger or in its own file.
|
||||||
|
- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. When views or triggers name the table being rebuilt, wrap the drop-and-rename in `PRAGMA legacy_alter_table = ON` ... `OFF`, then recreate the rebuilt table's own indexes and triggers.
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.
|
||||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Test line added by Claude Code.
|
||||||
292
server/src/admin-cli.js
Normal file
292
server/src/admin-cli.js
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
#!/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 role them@ngu.org editor
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
|
||||||
|
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
|
||||||
|
|
||||||
|
Roles, low to high. Each one can do everything the one above it
|
||||||
|
in this list can:
|
||||||
|
|
||||||
|
viewer read the CMS, change nothing
|
||||||
|
editor + create and update records
|
||||||
|
admin + delete records
|
||||||
|
superadmin + accounts, roles and sessions, via /admin/panel
|
||||||
|
|
||||||
|
add takes --role=editor, --name="Full Name". It defaults to
|
||||||
|
admin. Press enter at the password prompt and it generates one
|
||||||
|
and prints it once.
|
||||||
|
|
||||||
|
The list comes from auth.js rather than being repeated here, so
|
||||||
|
the CLI can't drift from what requireRole will actually accept.
|
||||||
|
|
||||||
|
This is also how the first superadmin is made — there's no
|
||||||
|
bootstrap path in the web interface, on purpose:
|
||||||
|
|
||||||
|
node src/admin-cli.js role you@ngu.org superadmin
|
||||||
|
|
||||||
|
Changing a password, a role, or disabling an account drops that
|
||||||
|
person's live sessions, so it takes effect now rather than in
|
||||||
|
30 days.
|
||||||
|
|
||||||
|
Nothing here refuses to demote or disable the last superadmin.
|
||||||
|
The panel does, because a misclick there locks everyone out;
|
||||||
|
here you're already root on the box holding the database, and a
|
||||||
|
recovery tool that argues with you isn't one.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { openDatabase, migrate } from "./db.js";
|
||||||
|
import { hashPassword, destroyAllSessionsFor, ROLES } 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRole(role) {
|
||||||
|
if (!ROLES.includes(role)) {
|
||||||
|
fail(`Role must be one of: ${ROLES.join(", ")}.`);
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printed, never enforced — see the note at the top of the file.
|
||||||
|
Worth saying out loud, because the person doing it is usually
|
||||||
|
tidying up accounts rather than thinking about lockouts. */
|
||||||
|
function warnIfLastSuper(db, user) {
|
||||||
|
if (user.role !== "superadmin" || user.is_active !== 1) return;
|
||||||
|
|
||||||
|
const { n } = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (n <= 1) {
|
||||||
|
console.warn(
|
||||||
|
"⚠ That's the last active superadmin. Nobody will be able to manage\n" +
|
||||||
|
" accounts from /admin/panel until you promote someone here.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(db, email, flags) {
|
||||||
|
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
|
||||||
|
|
||||||
|
const role = checkRole(flags.role ?? "admin");
|
||||||
|
|
||||||
|
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 setRole(db, email, role) {
|
||||||
|
const user = findUser(db, email);
|
||||||
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
checkRole(role);
|
||||||
|
|
||||||
|
if (user.role === role) {
|
||||||
|
console.log(`· ${email} is already ${role}, nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a demotion can strand the account list.
|
||||||
|
if (role !== "superadmin") warnIfLastSuper(db, user);
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET role = ? WHERE id = ?").run(role, user.id);
|
||||||
|
|
||||||
|
// The session they're holding was issued against the old role.
|
||||||
|
// Every check reads the row fresh, so it isn't a security hole —
|
||||||
|
// but their open tab would keep drawing buttons that now 403.
|
||||||
|
destroyAllSessionsFor(db, user.id);
|
||||||
|
|
||||||
|
console.log(`✓ ${email} is now ${role} (was ${user.role}), sessions ended`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(db, email, active) {
|
||||||
|
const user = findUser(db, email);
|
||||||
|
if (!user) fail(`No account for ${email}.`);
|
||||||
|
|
||||||
|
if (!active) warnIfLastSuper(db, user);
|
||||||
|
|
||||||
|
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
|
||||||
|
active ? 1 : 0,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
// Role and state are separate facts now. The old single column
|
||||||
|
// printed "disabled" over the top of the role, which hid what
|
||||||
|
// the account would go back to on enable.
|
||||||
|
const state = r.is_active ? r.role : `${r.role} (disabled)`;
|
||||||
|
const seen = r.last_login_at ?? "never";
|
||||||
|
console.log(
|
||||||
|
`${r.email.padEnd(32)} ${state.padEnd(22)} 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 [--role=editor]");
|
||||||
|
await add(db, email, flags);
|
||||||
|
break;
|
||||||
|
case "passwd":
|
||||||
|
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
|
||||||
|
await passwd(db, email);
|
||||||
|
break;
|
||||||
|
case "role": {
|
||||||
|
// Positional reads better for a two-argument command, but
|
||||||
|
// --role= is what `add` takes, so accept both rather than
|
||||||
|
// making people remember which is which.
|
||||||
|
const role = positional[1]?.trim().toLowerCase() ?? flags.role;
|
||||||
|
if (!email || !role) {
|
||||||
|
fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`);
|
||||||
|
}
|
||||||
|
setRole(db, email, role);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
if (!email) fail("Usage: admin-cli.js disable email@example.com");
|
||||||
|
setActive(db, email, false);
|
||||||
|
break;
|
||||||
|
case "enable":
|
||||||
|
if (!email) fail("Usage: admin-cli.js enable email@example.com");
|
||||||
|
setActive(db, email, true);
|
||||||
|
break;
|
||||||
|
case "list":
|
||||||
|
list(db);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("Commands: add, passwd, role, disable, enable, list");
|
||||||
|
console.log(`Roles: ${ROLES.join(", ")}`);
|
||||||
|
process.exit(command ? 1 : 0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
651
server/src/admin-crud.js
Normal file
651
server/src/admin-crud.js
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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}$/;
|
||||||
|
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
case "time": {
|
||||||
|
// <input type="time"> sends HH:MM, or HH:MM:SS when a step
|
||||||
|
// asks for seconds. Nothing here does, so seconds are dropped.
|
||||||
|
const value = String(raw).trim().slice(0, 5);
|
||||||
|
if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour.";
|
||||||
|
return CLOCK_TIME.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, rawId) {
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
|
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] = readExtension(db, ext, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of entity.children ?? []) {
|
||||||
|
row[child.key] = readChildren(db, child, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A 1:1 side table is found one of two ways. `idColumn` is the
|
||||||
|
original: the side table's key IS the parent's id, which is how
|
||||||
|
regions and person_private work. `owner` is the same block children
|
||||||
|
already use — a foreign key column plus an optional kind discriminator
|
||||||
|
— and it exists because a timeline entry is keyed by (ref_kind,
|
||||||
|
ref_id) rather than by the event's own slug. Same upsert either way;
|
||||||
|
only the WHERE differs. */
|
||||||
|
function extensionWhere(ext, id) {
|
||||||
|
if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] };
|
||||||
|
const where = [`${ext.owner.column} = ?`];
|
||||||
|
const params = [id];
|
||||||
|
if (ext.owner.kindColumn) {
|
||||||
|
where.push(`${ext.owner.kindColumn} = ?`);
|
||||||
|
params.push(ext.owner.kindValue);
|
||||||
|
}
|
||||||
|
return { sql: where.join(" AND "), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExtension(db, ext, id) {
|
||||||
|
const { sql, params } = extensionWhere(ext, id);
|
||||||
|
return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChildren(db, child, ownerId) {
|
||||||
|
const where = [`${child.owner.column} = ?`];
|
||||||
|
const params = [ownerId];
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* An entity whose id is an autoincrement integer is addressed by a
|
||||||
|
number, and a number arriving from a URL segment is a string. Every
|
||||||
|
comparison against the id column goes through here so the two can't
|
||||||
|
drift apart. */
|
||||||
|
export function normalizeId(entity, id) {
|
||||||
|
if (entity.idKind !== "auto") return id;
|
||||||
|
const n = Number(id);
|
||||||
|
if (!Number.isInteger(n)) throw new HttpError(404, "Not found.");
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRow(db, entity, payload) {
|
||||||
|
// A singleton's one row comes from its migration. There is no
|
||||||
|
// second one to create, and the CHECK on its id would refuse it.
|
||||||
|
if (entity.singleton) {
|
||||||
|
throw new HttpError(405, "There is only one of these; edit it instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// idKind "auto": the table assigns the id, so there is nothing to
|
||||||
|
// validate, nothing to check for collisions, and nothing for the
|
||||||
|
// client to have sent. Timeline entries use this — they have no
|
||||||
|
// natural name to slug, and one gets created every time somebody
|
||||||
|
// ticks a checkbox on an event.
|
||||||
|
const auto = entity.idKind === "auto";
|
||||||
|
const id = auto
|
||||||
|
? null
|
||||||
|
: String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!auto) {
|
||||||
|
if (entity.idKind === "slug" && !SLUG.test(id)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newId = id;
|
||||||
|
|
||||||
|
wrapDbErrors(() =>
|
||||||
|
tx(db, () => {
|
||||||
|
if (auto) {
|
||||||
|
const names = Object.keys(values);
|
||||||
|
// Every column omitted is legitimate here: a blank entry that
|
||||||
|
// takes all its defaults. INSERT INTO t () VALUES () is not
|
||||||
|
// valid SQL, so that case needs DEFAULT VALUES.
|
||||||
|
const result = names.length
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO ${entity.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})`,
|
||||||
|
)
|
||||||
|
.run(...Object.values(values))
|
||||||
|
: db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run();
|
||||||
|
// better-sqlite3 and node:sqlite disagree about BigInt here.
|
||||||
|
newId = Number(result.lastInsertRowid);
|
||||||
|
} else {
|
||||||
|
const names = [entity.idColumn, ...Object.keys(values)];
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ${entity.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})`,
|
||||||
|
).run(id, ...Object.values(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
writeExtensions(db, entity, newId, payload, values);
|
||||||
|
writeChildren(db, entity, newId, payload, values);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return readRow(db, entity, newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRow(db, entity, rawId, payload) {
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
|
const current = db
|
||||||
|
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
|
||||||
|
.get(id);
|
||||||
|
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, rawId) {
|
||||||
|
// Deleting a singleton would leave the page it drives with nothing
|
||||||
|
// to read, and the admin with no way to make another.
|
||||||
|
if (entity.singleton) {
|
||||||
|
throw new HttpError(405, "This can't be deleted, only edited.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizeId(entity, rawId);
|
||||||
|
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, or a checkbox was unticked — so its row (and anything
|
||||||
|
// cascading off it) goes.
|
||||||
|
const gone = extensionWhere(ext, id);
|
||||||
|
db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// The owning columns come first, then whatever the form sent.
|
||||||
|
const ownNames = [];
|
||||||
|
const ownParams = [];
|
||||||
|
if (ext.owner) {
|
||||||
|
ownNames.push(ext.owner.column);
|
||||||
|
ownParams.push(id);
|
||||||
|
if (ext.owner.kindColumn) {
|
||||||
|
ownNames.push(ext.owner.kindColumn);
|
||||||
|
ownParams.push(ext.owner.kindValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ownNames.push(ext.idColumn);
|
||||||
|
ownParams.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = [...ownNames, ...Object.keys(values)];
|
||||||
|
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
|
||||||
|
|
||||||
|
// What makes this row the same row on a second save. Defaults to
|
||||||
|
// the id column; an owned extension declares the unique index its
|
||||||
|
// owning columns form.
|
||||||
|
const conflict = ext.conflict ?? ownNames;
|
||||||
|
|
||||||
|
// Upsert rather than delete-and-insert: deleting a regions row
|
||||||
|
// would cascade its region_areas away underneath us, and deleting
|
||||||
|
// a timeline row would take its people with it. With every
|
||||||
|
// optional column omitted there is nothing to set, so the
|
||||||
|
// conflict clause has to degrade to DO NOTHING or the SQL is
|
||||||
|
// syntactically invalid.
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ${ext.table} (${names.join(", ")})
|
||||||
|
VALUES (${names.map(() => "?").join(", ")})
|
||||||
|
ON CONFLICT(${conflict.join(", ")}) ${
|
||||||
|
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
|
||||||
|
}`,
|
||||||
|
).run(...ownParams, ...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.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The polymorphic tables stand in for a foreign key with a
|
||||||
|
// BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none
|
||||||
|
// of the patterns above — so without this, pointing a content
|
||||||
|
// block, link or timeline entry at a row that isn't there is a 500
|
||||||
|
// rather than something the form can show.
|
||||||
|
const ghost = /^(\w+): no such (\w+)$/.exec(message);
|
||||||
|
if (ghost) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
`That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
server/src/admin-schema-sync.js
Normal file
144
server/src/admin-schema-sync.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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 ?? [],
|
||||||
|
// An extension is keyed either by the parent's own id or by an
|
||||||
|
// owner block, the same one children use. Both sets of columns
|
||||||
|
// are filled in by the engine, never by the form.
|
||||||
|
engineSupplied: [
|
||||||
|
ext.idColumn,
|
||||||
|
ext.owner?.column,
|
||||||
|
ext.owner?.kindColumn,
|
||||||
|
...(ext.touch ? ["updated_at"] : []),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
822
server/src/admin-schema.js
Normal file
822
server/src/admin-schema.js
Normal file
|
|
@ -0,0 +1,822 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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
|
||||||
|
singleton the one id this entity ever has; the engine
|
||||||
|
refuses create and delete (see front_page)
|
||||||
|
|
||||||
|
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 time = (name, opts = {}) => ({ name, type: "time", ...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 editable half of a timeline entry, shared by the standalone
|
||||||
|
editor and by the in_timeline extension on events and organizations.
|
||||||
|
|
||||||
|
occurred_on is text, not date. "2012" and "2025-07" are legitimate
|
||||||
|
values — a backfilled entry often knows the year and nothing more —
|
||||||
|
and the date coercion would reject both. `precision` is what says how
|
||||||
|
much of it to believe. */
|
||||||
|
const timelineFields = [
|
||||||
|
text("occurred_on"),
|
||||||
|
enumeration("precision", ["year", "month", "day"]),
|
||||||
|
text("title"),
|
||||||
|
text("blurb"),
|
||||||
|
text("meta"),
|
||||||
|
text("link_url"),
|
||||||
|
bool("is_featured"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* The extension that the in_timeline checkbox drives. Ticked, the row
|
||||||
|
is upserted; unticked, writeExtensions deletes it. Both happen in the
|
||||||
|
parent's transaction, so the flag and the row cannot disagree.
|
||||||
|
|
||||||
|
The conflict target is the UNIQUE (ref_kind, ref_id) constraint
|
||||||
|
on timeline_entries, which is also what stops a second save creating a
|
||||||
|
duplicate instead of updating the first. */
|
||||||
|
const timelineExtension = (refKind) => ({
|
||||||
|
key: "timeline",
|
||||||
|
table: "timeline_entries",
|
||||||
|
owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind },
|
||||||
|
conflict: ["ref_kind", "ref_id"],
|
||||||
|
when: { column: "in_timeline", value: 1 },
|
||||||
|
columns: [
|
||||||
|
// Fixed for this end: an event's entry is always an event entry.
|
||||||
|
// Declared as a default rather than a form field so the column is
|
||||||
|
// written without asking.
|
||||||
|
enumeration(
|
||||||
|
"kind",
|
||||||
|
["milestone", "event", "organization", "award", "people"],
|
||||||
|
{ default: refKind === "organization" ? "organization" : refKind },
|
||||||
|
),
|
||||||
|
...timelineFields,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The two polymorphic collections, parameterised by owner_kind. */
|
||||||
|
const linksChild = (ownerKind) => ({
|
||||||
|
key: "links",
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Hosts. One row is one host, ordered, each either an organization
|
||||||
|
or a person — the CHECK on event_hosts rejects both and the blank
|
||||||
|
filter drops neither, so the only bad row that reaches SQLite is
|
||||||
|
one with both selects filled, and that comes back keyed to the
|
||||||
|
row like any other field error.
|
||||||
|
|
||||||
|
Not parameterised the way links and blocks are: this table is
|
||||||
|
events-only, and the owner column says so.
|
||||||
|
|
||||||
|
sort_order isn't declared. The engine writes it from the row's
|
||||||
|
position because `order` is "sort_order", which is what makes
|
||||||
|
the first row the one v_events takes the logo and colour from. */
|
||||||
|
const hostsChild = {
|
||||||
|
key: "event_hosts",
|
||||||
|
table: "event_hosts",
|
||||||
|
owner: { column: "event_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("org_id"), text("person_id")],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const organizations = {
|
||||||
|
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"),
|
||||||
|
bool("in_timeline"),
|
||||||
|
],
|
||||||
|
|
||||||
|
extensions: [
|
||||||
|
timelineExtension("organization"),
|
||||||
|
{
|
||||||
|
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 ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Column suffixes for the series weekday flags, Sunday first to
|
||||||
|
match Date#getDay. */
|
||||||
|
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
|
||||||
|
const events = {
|
||||||
|
key: "events",
|
||||||
|
table: "events",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"scope_id",
|
||||||
|
"event_type",
|
||||||
|
"date_label",
|
||||||
|
"starts_on",
|
||||||
|
"status",
|
||||||
|
"is_published",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
// No host filter: hosts are rows in another table now, and the
|
||||||
|
// engine's filters are columns on this one. The events a host
|
||||||
|
// owns are on that host's own page.
|
||||||
|
filters: ["scope_id", "event_type", "status", "is_published"],
|
||||||
|
search: ["title", "id", "theme"],
|
||||||
|
order: "starts_on IS NULL, starts_on DESC, title",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("scope_id", { required: true }),
|
||||||
|
|
||||||
|
// What kind of gathering, as against scope_id's whose gathering
|
||||||
|
// it is. Declared required even though the column has a
|
||||||
|
// DEFAULT: every select renders a blank first option, so without
|
||||||
|
// it a new event files itself as a retreat while nobody is
|
||||||
|
// looking. An existing row always loads with its value set, so
|
||||||
|
// this only ever asks on create.
|
||||||
|
enumeration(
|
||||||
|
"event_type",
|
||||||
|
["retreat", "class", "workshop", "meeting", "other"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
|
||||||
|
text("title", { required: true }),
|
||||||
|
text("theme"),
|
||||||
|
text("tagline"),
|
||||||
|
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"),
|
||||||
|
bool("in_timeline"),
|
||||||
|
|
||||||
|
// A repeating schedule. Columns rather than a side table: the
|
||||||
|
// schedule is always exactly one per event, and the public view
|
||||||
|
// is SELECT e.*, so it reaches the site with no join. Ignored
|
||||||
|
// while is_series is 0. The events table in the schema says
|
||||||
|
// what each means.
|
||||||
|
bool("is_series"),
|
||||||
|
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
|
||||||
|
int("series_interval"),
|
||||||
|
...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)),
|
||||||
|
time("series_start_time"),
|
||||||
|
time("series_end_time"),
|
||||||
|
int("series_count"),
|
||||||
|
],
|
||||||
|
|
||||||
|
extensions: [timelineExtension("event")],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
hostsChild,
|
||||||
|
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",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["is_published"],
|
||||||
|
search: ["display_name", "sort_name", "id"],
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
|
||||||
|
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.
|
||||||
|
const teams = {
|
||||||
|
key: "teams",
|
||||||
|
table: "teams",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"],
|
||||||
|
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. 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", "is_published", "sort_order"],
|
||||||
|
filters: ["org_id", "is_published"],
|
||||||
|
search: ["name", "id", "description"],
|
||||||
|
order: "org_id, sort_order, name",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
text("org_id"),
|
||||||
|
text("name", { required: true }),
|
||||||
|
text("description"),
|
||||||
|
text("logo"),
|
||||||
|
bool("is_published"),
|
||||||
|
int("sort_order"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Timeline ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The history page's spine, and the only entity whose id the table
|
||||||
|
// assigns. There is nothing to slug: an entry referencing an event has
|
||||||
|
// no name of its own, and one gets created every time somebody ticks a
|
||||||
|
// checkbox. idKind "auto" is what lets createRow skip the id entirely.
|
||||||
|
//
|
||||||
|
// ref_kind and ref_id are writable here and only here. The extension on
|
||||||
|
// events and organizations owns those two columns for rows it created,
|
||||||
|
// which is why they aren't in timelineFields.
|
||||||
|
//
|
||||||
|
// Deleting an entry takes its people with it (ON DELETE CASCADE) and
|
||||||
|
// nothing points at an entry, so the delete-and-reinsert child engine
|
||||||
|
// is safe on this one.
|
||||||
|
const timeline = {
|
||||||
|
key: "timeline",
|
||||||
|
table: "timeline_entries",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "auto",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"kind",
|
||||||
|
"ref_kind",
|
||||||
|
"ref_id",
|
||||||
|
"occurred_on",
|
||||||
|
"title",
|
||||||
|
"is_featured",
|
||||||
|
"is_published",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
filters: ["kind", "ref_kind", "is_featured", "is_published"],
|
||||||
|
search: ["title", "blurb", "meta", "ref_id"],
|
||||||
|
// Undated entries sort last rather than first, so a missing date
|
||||||
|
// reads as something to fix instead of something to scroll past.
|
||||||
|
order: "occurred_on IS NULL, occurred_on DESC, sort_order",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
enumeration(
|
||||||
|
"kind",
|
||||||
|
["milestone", "event", "organization", "award", "people"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
enumeration("ref_kind", ["event", "organization", "award", "person", "team"]),
|
||||||
|
text("ref_id"),
|
||||||
|
...timelineFields,
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "people",
|
||||||
|
table: "timeline_entry_people",
|
||||||
|
owner: { column: "entry_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("person_id", { required: true }), text("note")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Front page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A singleton: one row, id 'home', seeded by the schema and never
|
||||||
|
created by the admin. `singleton` tells the engine to refuse create
|
||||||
|
and delete, and the CHECK on front_page.id is what makes a second
|
||||||
|
row impossible even without it.
|
||||||
|
|
||||||
|
Every collection here is owned by page_id and replaced wholesale.
|
||||||
|
That is safe for the same reason it is for links and blocks —
|
||||||
|
nothing has a foreign key into these tables — and paths carry
|
||||||
|
their actions as a nested collection, the shape content blocks
|
||||||
|
and their items already use. */
|
||||||
|
|
||||||
|
const frontPage = {
|
||||||
|
key: "front_page",
|
||||||
|
table: "front_page",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
singleton: "home",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: ["id", "headline", "hero_mode", "updated_at"],
|
||||||
|
filters: [],
|
||||||
|
search: [],
|
||||||
|
order: "id",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
enumeration("hero_mode", ["brand", "photos", "livestream"]),
|
||||||
|
text("eyebrow"),
|
||||||
|
text("headline"),
|
||||||
|
text("subhead"),
|
||||||
|
text("primary_label"),
|
||||||
|
text("primary_url"),
|
||||||
|
text("secondary_label"),
|
||||||
|
text("secondary_url"),
|
||||||
|
int("slide_seconds"),
|
||||||
|
text("livestream_url"),
|
||||||
|
text("livestream_title"),
|
||||||
|
text("countdown_event_id"),
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "slides",
|
||||||
|
table: "front_page_slides",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("media", { required: true }),
|
||||||
|
text("alt"),
|
||||||
|
text("caption"),
|
||||||
|
text("link_url"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sections",
|
||||||
|
table: "front_page_sections",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
enumeration(
|
||||||
|
"section",
|
||||||
|
["countdown", "retreats", "calendar", "stats", "timeline", "connect"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
text("title"),
|
||||||
|
text("blurb"),
|
||||||
|
bool("is_hidden"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stats",
|
||||||
|
table: "front_page_stats",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("label", { required: true }),
|
||||||
|
enumeration("source", [
|
||||||
|
"manual",
|
||||||
|
"years_since",
|
||||||
|
"regions",
|
||||||
|
"chapters",
|
||||||
|
"partners",
|
||||||
|
"events_held",
|
||||||
|
"retreats_held",
|
||||||
|
"people",
|
||||||
|
"awards_given",
|
||||||
|
]),
|
||||||
|
text("value"),
|
||||||
|
text("suffix"),
|
||||||
|
text("note"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paths",
|
||||||
|
table: "front_page_paths",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("label", { required: true }), text("icon"), text("blurb")],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
table: "front_page_path_actions",
|
||||||
|
owner: { column: "path_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("label", { required: true }),
|
||||||
|
text("description"),
|
||||||
|
text("url", { required: true }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITIES = {
|
||||||
|
organizations,
|
||||||
|
events,
|
||||||
|
people,
|
||||||
|
teams,
|
||||||
|
awards,
|
||||||
|
timeline,
|
||||||
|
front_page: frontPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 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_scopes: "SELECT id, name AS label FROM event_scopes 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",
|
||||||
|
|
||||||
|
// One flat list the ref picker filters by ref_kind, rather than five
|
||||||
|
// dropdowns of which four are always wrong. `kind` is the discriminator
|
||||||
|
// the client's filterBy matches on; org_kind disambiguates the label,
|
||||||
|
// since a region and a chapter can share a name.
|
||||||
|
timeline_refs: `
|
||||||
|
SELECT 'event' AS kind, id, title AS label FROM events
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'award', id, name FROM awards
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'person', id, display_name FROM people
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'team', id, name FROM teams
|
||||||
|
ORDER BY kind, label`,
|
||||||
|
|
||||||
|
// The awarding organization is folded into the label instead,
|
||||||
|
// because a person can receive an award from any organization —
|
||||||
|
// there is nothing to filter on, only something to disambiguate
|
||||||
|
// 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`,
|
||||||
|
};
|
||||||
230
server/src/auth.js
Normal file
230
server/src/auth.js
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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 const ROLES = ["viewer", "editor", "admin", "superadmin"];
|
||||||
|
const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 };
|
||||||
|
|
||||||
|
export function requireRole(...roles) {
|
||||||
|
const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity));
|
||||||
|
return async (c, next) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
if (!user || (RANK[user.role] ?? 0) < need) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,23 @@ export function tx(db, fn) {
|
||||||
|
|
||||||
Migrations only ever go forward. To undo something, write a
|
Migrations only ever go forward. To undo something, write a
|
||||||
new migration.
|
new migration.
|
||||||
|
|
||||||
|
Foreign keys are off while migrations run, the recipe from
|
||||||
|
the SQLite docs for changing a table's shape. PRAGMA
|
||||||
|
foreign_keys is a no-op inside a transaction, so it has to be
|
||||||
|
set here, around the per-file transactions, rather than in
|
||||||
|
the file. With it on, rebuilding a table something references
|
||||||
|
(drop the old one, rename the new one into place) either
|
||||||
|
cascades into the children or fails the commit. Instead each
|
||||||
|
file ends with a foreign_key_check, and any orphan it leaves
|
||||||
|
rolls that file back.
|
||||||
|
|
||||||
|
The first file is the baseline: the whole schema as of its
|
||||||
|
version, consolidated from the migrations before it. It only
|
||||||
|
ever runs on an empty database. One stuck between v1 and the
|
||||||
|
baseline was built by those older files and has to be brought
|
||||||
|
up by a release that still has them; running the baseline over
|
||||||
|
it would fail halfway on CREATE TABLE, so this refuses first.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function migrate(db, { log = console.log } = {}) {
|
export function migrate(db, { log = console.log } = {}) {
|
||||||
|
|
@ -91,8 +108,20 @@ export function migrate(db, { log = console.log } = {}) {
|
||||||
.filter((f) => f.endsWith(".sql"))
|
.filter((f) => f.endsWith(".sql"))
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
let applied = 0;
|
const baseline = files.length > 0 ? Number.parseInt(files[0].slice(0, 3), 10) : 0;
|
||||||
|
if (current > 0 && current < baseline) {
|
||||||
|
throw new Error(
|
||||||
|
`Database is at schema v${current}, older than the v${baseline} baseline ` +
|
||||||
|
`(${files[0]}). Upgrade it to v${baseline} with a release from before the ` +
|
||||||
|
`migrations were consolidated, then run this one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let applied = 0;
|
||||||
|
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
|
||||||
|
db.exec("PRAGMA foreign_keys = OFF");
|
||||||
|
|
||||||
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const version = Number.parseInt(file.slice(0, 3), 10);
|
const version = Number.parseInt(file.slice(0, 3), 10);
|
||||||
|
|
||||||
|
|
@ -105,6 +134,16 @@ export function migrate(db, { log = console.log } = {}) {
|
||||||
|
|
||||||
tx(db, () => {
|
tx(db, () => {
|
||||||
db.exec(sql);
|
db.exec(sql);
|
||||||
|
|
||||||
|
const orphans = db.prepare("PRAGMA foreign_key_check").all();
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
const where = orphans
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((o) => `${o.table} row ${o.rowid} → ${o.parent}`)
|
||||||
|
.join(", ");
|
||||||
|
throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Not parameterisable, but version is a validated integer.
|
// Not parameterisable, but version is a validated integer.
|
||||||
db.exec(`PRAGMA user_version = ${version}`);
|
db.exec(`PRAGMA user_version = ${version}`);
|
||||||
});
|
});
|
||||||
|
|
@ -112,6 +151,9 @@ export function migrate(db, { log = console.log } = {}) {
|
||||||
log(`migrated → ${file}`);
|
log(`migrated → ${file}`);
|
||||||
applied += 1;
|
applied += 1;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (enforced) db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
}
|
||||||
|
|
||||||
const final = db.prepare("PRAGMA user_version").get().user_version;
|
const final = db.prepare("PRAGMA user_version").get().user_version;
|
||||||
if (applied === 0) log(`schema up to date (v${final})`);
|
if (applied === 0) log(`schema up to date (v${final})`);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,17 @@ 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 history from "./routes/history.js";
|
||||||
|
import home from "./routes/home.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 panel from "./routes/panel.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 +35,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 +55,22 @@ app.get("/api/health", (c) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
app.route("/api", content);
|
app.route("/api", content);
|
||||||
|
app.route("/api", people);
|
||||||
|
app.route("/api", history);
|
||||||
|
app.route("/api", home);
|
||||||
|
|
||||||
// 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/admin/panel", panel);
|
||||||
|
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) => {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- 001_init.sql
|
|
||||||
--
|
|
||||||
-- Placeholder so the runner has something to do on first boot and
|
|
||||||
-- you can confirm the plumbing works end to end. The real tables
|
|
||||||
-- (regions, region_states, state_grid, chapters, events, feedback)
|
|
||||||
-- land in 002.
|
|
||||||
--
|
|
||||||
-- Once 002 exists you can leave this file alone. Never edit a
|
|
||||||
-- migration that has already run anywhere; write the next one.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now'));
|
|
||||||
|
|
@ -1,700 +0,0 @@
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- 002_schema.sql
|
|
||||||
--
|
|
||||||
-- Four things own a card and a page: organizations, events, people
|
|
||||||
-- and teams. They share two tables — content_blocks for long-form
|
|
||||||
-- description and links for buttons and socials — so a bio, an
|
|
||||||
-- event description and a region's page all render through one
|
|
||||||
-- component.
|
|
||||||
--
|
|
||||||
-- Tables are STRICT, so a column declared TEXT refuses an integer
|
|
||||||
-- rather than quietly storing one. Worth it when the eventual
|
|
||||||
-- writer is a web form.
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- ORGANIZATIONS
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Regions, chapters, partners and NGU itself. They differ in a
|
|
||||||
-- handful of fields, which live in side tables keyed by the same
|
|
||||||
-- id, so events get one real foreign key to their host instead of
|
|
||||||
-- a type/id pair SQLite can't check.
|
|
||||||
--
|
|
||||||
-- location_label is the display override for what the structured
|
|
||||||
-- fields can't express: "Online", "Various venues", "Unity Village,
|
|
||||||
-- MO". Read it first, fall back to composing from the parts.
|
|
||||||
CREATE TABLE organizations (
|
|
||||||
id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood'
|
|
||||||
kind TEXT NOT NULL
|
|
||||||
CHECK (kind IN ('national', 'region', 'chapter', 'partner')),
|
|
||||||
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
short_name TEXT,
|
|
||||||
tagline TEXT, -- one line, for the card
|
|
||||||
color TEXT,
|
|
||||||
logo TEXT, -- filename in public/org-logos/
|
|
||||||
|
|
||||||
venue TEXT,
|
|
||||||
address TEXT,
|
|
||||||
locality TEXT,
|
|
||||||
state_code TEXT, -- US only
|
|
||||||
country TEXT NOT NULL DEFAULT 'US',
|
|
||||||
location_label TEXT,
|
|
||||||
latitude REAL,
|
|
||||||
longitude REAL,
|
|
||||||
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
|
|
||||||
|
|
||||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order);
|
|
||||||
CREATE INDEX organizations_state_idx ON organizations (state_code);
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE regions (
|
|
||||||
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
|
|
||||||
scope TEXT NOT NULL
|
|
||||||
CHECK (scope IN ('domestic', 'international', 'virtual')),
|
|
||||||
map_note TEXT
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
|
|
||||||
-- Which map areas a region covers, and how much of each.
|
|
||||||
--
|
|
||||||
-- area_code is a plain string matched at render time against the
|
|
||||||
-- keys in mapGrid.js. No foreign key, because the thing it points
|
|
||||||
-- at isn't in this database. An unrecognised code paints nothing,
|
|
||||||
-- which is how Africa and the UK exist as regions with no tile.
|
|
||||||
--
|
|
||||||
-- Replaces both GROUPS.states and SPLITS. A region owning a whole
|
|
||||||
-- area has share 1.0 and no edge. A shared area gets one row per
|
|
||||||
-- region, each naming its own slice, so there's no primary and
|
|
||||||
-- secondary to keep straight.
|
|
||||||
CREATE TABLE region_areas (
|
|
||||||
region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE,
|
|
||||||
area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA'
|
|
||||||
share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1),
|
|
||||||
edge TEXT CHECK (edge IN ('top', 'bottom')),
|
|
||||||
note TEXT, -- 'north', 'Salt Lake City area'
|
|
||||||
PRIMARY KEY (region_id, area_code)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX region_areas_area_idx ON region_areas (area_code);
|
|
||||||
|
|
||||||
|
|
||||||
-- region_id is stored rather than derived from the state. Deriving
|
|
||||||
-- it is what forced the per-chapter override in split states; the
|
|
||||||
-- admin form should default it from the state and only ask when the
|
|
||||||
-- state has more than one row in region_areas.
|
|
||||||
--
|
|
||||||
-- No `leads` column. Who runs a chapter is an affiliation, exactly
|
|
||||||
-- as it is for every other organization.
|
|
||||||
CREATE TABLE chapters (
|
|
||||||
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
|
|
||||||
region_id TEXT REFERENCES regions (id) ON DELETE SET NULL,
|
|
||||||
meets TEXT, -- '2nd Sundays, 6:00pm'
|
|
||||||
started TEXT -- 'Since 2021'
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX chapters_region_idx ON chapters (region_id);
|
|
||||||
|
|
||||||
|
|
||||||
-- Partners get no side table. Everything they need is already on
|
|
||||||
-- organizations, and a table holding nothing but a primary key is
|
|
||||||
-- a place for confusion rather than data.
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- EVENTS
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Sections are defined in Retreats.jsx, which owns their titles,
|
|
||||||
-- accents, default colours and backgrounds. This table exists only
|
|
||||||
-- so section_id can be a real foreign key: an unrecognised value
|
|
||||||
-- would make an event vanish from the page with no error anywhere,
|
|
||||||
-- which is a bug someone hunts for an hour.
|
|
||||||
--
|
|
||||||
-- `name` is an internal label for the eventual admin dropdown. The
|
|
||||||
-- site never renders it.
|
|
||||||
CREATE TABLE event_sections (
|
|
||||||
id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner'
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
|
|
||||||
-- Dates are stored three ways on purpose:
|
|
||||||
--
|
|
||||||
-- starts_on / ends_on ISO dates, nullable. What sorting and the
|
|
||||||
-- upcoming/past split run on.
|
|
||||||
-- date_label what the card shows. Real data includes
|
|
||||||
-- "March/April 2026", which no date type
|
|
||||||
-- holds and no formatter should reproduce.
|
|
||||||
-- status an override. Null derives from ends_on,
|
|
||||||
-- so there's no flag to remember to flip.
|
|
||||||
CREATE TABLE events (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
section_id TEXT NOT NULL REFERENCES event_sections (id),
|
|
||||||
host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
theme TEXT,
|
|
||||||
tagline TEXT,
|
|
||||||
|
|
||||||
starts_on TEXT, -- 'YYYY-MM-DD'
|
|
||||||
ends_on TEXT,
|
|
||||||
date_label TEXT,
|
|
||||||
status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')),
|
|
||||||
|
|
||||||
venue TEXT,
|
|
||||||
address TEXT,
|
|
||||||
locality TEXT,
|
|
||||||
state_code TEXT,
|
|
||||||
country TEXT NOT NULL DEFAULT 'US',
|
|
||||||
location_label TEXT,
|
|
||||||
latitude REAL,
|
|
||||||
longitude REAL,
|
|
||||||
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
|
|
||||||
|
|
||||||
org_logo TEXT, -- null → host's logo
|
|
||||||
event_logo TEXT,
|
|
||||||
color TEXT, -- null → host's, then the page's
|
|
||||||
gradient TEXT,
|
|
||||||
|
|
||||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order);
|
|
||||||
CREATE INDEX events_host_idx ON events (host_org_id);
|
|
||||||
CREATE INDEX events_date_idx ON events (starts_on);
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- PEOPLE
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Public by design. Everything in this table can appear on a card,
|
|
||||||
-- and is_published = 0 is the only thing between a row and the
|
|
||||||
-- open web — hence the default of 0, unlike organizations.
|
|
||||||
-- Anything that must never be served lives in person_private, so a
|
|
||||||
-- careless SELECT * can't leak it.
|
|
||||||
--
|
|
||||||
-- Bio goes in content_blocks: 'card' slot for the two lines under
|
|
||||||
-- a photo, 'body' slot for the full page with headings and lists.
|
|
||||||
-- Socials and personal sites go in links.
|
|
||||||
CREATE TABLE people (
|
|
||||||
id TEXT PRIMARY KEY, -- slug: 'jane-doe'
|
|
||||||
display_name TEXT NOT NULL, -- 'Jane Doe'
|
|
||||||
sort_name TEXT, -- 'Doe, Jane' — list ordering
|
|
||||||
pronouns TEXT, -- 'she/her'
|
|
||||||
tagline TEXT, -- fallback when no title applies
|
|
||||||
photo TEXT, -- filename in public/people/
|
|
||||||
|
|
||||||
public_email TEXT, -- safe to print on the site
|
|
||||||
public_phone TEXT,
|
|
||||||
|
|
||||||
locality TEXT,
|
|
||||||
state_code TEXT,
|
|
||||||
country TEXT NOT NULL DEFAULT 'US',
|
|
||||||
location_label TEXT,
|
|
||||||
|
|
||||||
is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name);
|
|
||||||
|
|
||||||
|
|
||||||
-- Never joined into a public response. A separate table rather than
|
|
||||||
-- extra columns so the boundary is structural instead of a rule
|
|
||||||
-- someone has to remember.
|
|
||||||
--
|
|
||||||
-- birth_date rather than age: an age column is wrong within a year
|
|
||||||
-- of being written. Derive it when needed, and consider first
|
|
||||||
-- whether you need it at all — Planning Center already holds
|
|
||||||
-- registration data, and the least sensitive record is the one you
|
|
||||||
-- never made.
|
|
||||||
CREATE TABLE person_private (
|
|
||||||
person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE,
|
|
||||||
birth_date TEXT, -- 'YYYY-MM-DD'
|
|
||||||
private_email TEXT,
|
|
||||||
private_phone TEXT,
|
|
||||||
address TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
|
|
||||||
-- ── Teams ──────────────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- A team belongs to exactly one organization: NGU national has a
|
|
||||||
-- Board and a Leadership Team, a region or chapter can have its
|
|
||||||
-- own. An organization with a flat structure needs none — its
|
|
||||||
-- affiliations simply carry no team_id.
|
|
||||||
--
|
|
||||||
-- UNIQUE (id, org_id) looks redundant against the primary key, and
|
|
||||||
-- it is — except that it gives affiliations a composite foreign key
|
|
||||||
-- to point at, which is what stops someone filing a person under a
|
|
||||||
-- team belonging to a different organization.
|
|
||||||
CREATE TABLE teams (
|
|
||||||
id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
|
|
||||||
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
tagline TEXT,
|
|
||||||
color TEXT,
|
|
||||||
logo TEXT,
|
|
||||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (id, org_id)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
|
|
||||||
|
|
||||||
|
|
||||||
-- ── Affiliations ───────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- The leadership list for every organization on the site. A chapter
|
|
||||||
-- lead, a regional coordinator and a national board member are the
|
|
||||||
-- same kind of row; only org_id differs.
|
|
||||||
--
|
|
||||||
-- One person can hold several: chapter lead in Lynnwood and board
|
|
||||||
-- member nationally are two rows.
|
|
||||||
--
|
|
||||||
-- ended_on null means current. Keeping past roles rather than
|
|
||||||
-- deleting them is what makes an alumni list possible later.
|
|
||||||
--
|
|
||||||
-- is_owner marks authority within the organization, and is
|
|
||||||
-- deliberately orthogonal to role — a board member and a chapter
|
|
||||||
-- lead can both be owners, a long-serving volunteer isn't. It
|
|
||||||
-- drives billing order on cards. It is NOT an edit permission:
|
|
||||||
-- when the admin pages arrive, who may change an organization's
|
|
||||||
-- content belongs in its own table, because the person who
|
|
||||||
-- maintains a page is often not the person who runs the chapter.
|
|
||||||
--
|
|
||||||
-- Deleting a team that still has members fails rather than
|
|
||||||
-- silently detaching them. That's the composite foreign key doing
|
|
||||||
-- its job; clear or reassign the members first.
|
|
||||||
CREATE TABLE affiliations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
|
||||||
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
|
||||||
team_id TEXT,
|
|
||||||
|
|
||||||
title TEXT, -- 'Board Chair', 'Chapter Lead'
|
|
||||||
role TEXT NOT NULL DEFAULT 'member'
|
|
||||||
CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')),
|
|
||||||
is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)),
|
|
||||||
started_on TEXT,
|
|
||||||
ended_on TEXT, -- null = current
|
|
||||||
|
|
||||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX affiliations_person_idx ON affiliations (person_id);
|
|
||||||
CREATE INDEX affiliations_org_idx
|
|
||||||
ON affiliations (org_id, is_public, is_owner DESC, sort_order);
|
|
||||||
CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order);
|
|
||||||
|
|
||||||
|
|
||||||
-- ── People at events ───────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- Both the public billing (speakers, leaders) and the private
|
|
||||||
-- record of who attended, distinguished by is_public rather than by
|
|
||||||
-- table. It defaults to 0, so a new row is invisible until someone
|
|
||||||
-- decides otherwise — the right way round for this.
|
|
||||||
--
|
|
||||||
-- If attendance ever becomes real check-in data synced from
|
|
||||||
-- Planning Center, that belongs in its own table. This one is for
|
|
||||||
-- the handful of names worth remembering per event.
|
|
||||||
CREATE TABLE event_people (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
|
|
||||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
role TEXT NOT NULL DEFAULT 'attendee'
|
|
||||||
CHECK (role IN ('speaker', 'leader', 'facilitator', 'host',
|
|
||||||
'musician', 'volunteer', 'attendee')),
|
|
||||||
title TEXT, -- 'Keynote Speaker'
|
|
||||||
is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
UNIQUE (event_id, person_id, role)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order);
|
|
||||||
CREATE INDEX event_people_person_idx ON event_people (person_id);
|
|
||||||
|
|
||||||
|
|
||||||
-- ── Awards ─────────────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- An award exists independently of who won it, which is why it's
|
|
||||||
-- two tables and not a text column on people.
|
|
||||||
CREATE TABLE awards (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
logo TEXT,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE TABLE person_awards (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
|
||||||
award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE,
|
|
||||||
event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented
|
|
||||||
awarded_on TEXT,
|
|
||||||
citation TEXT,
|
|
||||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
|
|
||||||
UNIQUE (person_id, award_id, awarded_on)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX person_awards_person_idx ON person_awards (person_id);
|
|
||||||
|
|
||||||
|
|
||||||
-- ── Curated lists ──────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- Teams and affiliations are structural: they describe how an
|
|
||||||
-- organization is actually run. Lists are editorial: "2026 Retreat
|
|
||||||
-- Speakers", "Founders", anything a page wants to show that isn't
|
|
||||||
-- an org chart. If it turns out affiliations cover everything, this
|
|
||||||
-- pair is easy to drop — nothing depends on it.
|
|
||||||
CREATE TABLE people_lists (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
blurb TEXT,
|
|
||||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE TABLE people_list_members (
|
|
||||||
list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE,
|
|
||||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
|
||||||
note TEXT, -- overrides tagline in this list
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (list_id, person_id)
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- CONTENT BLOCKS
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Long-form description as ordered rows, shared by all four card
|
|
||||||
-- types.
|
|
||||||
--
|
|
||||||
-- slot 'card' is the short version on the tile — an event's
|
|
||||||
-- desc_a and desc_b become two paragraph blocks here.
|
|
||||||
-- 'body' is the full page. Same renderer, different query.
|
|
||||||
--
|
|
||||||
-- Blocks with children (list, links) use content_block_items.
|
|
||||||
--
|
|
||||||
-- owner_kind + owner_id is polymorphic, which SQLite can't express
|
|
||||||
-- as a foreign key. The triggers below do the work a FK would.
|
|
||||||
CREATE TABLE content_blocks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner_kind TEXT NOT NULL
|
|
||||||
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
type TEXT NOT NULL
|
|
||||||
CHECK (type IN ('heading', 'subheading', 'paragraph',
|
|
||||||
'list', 'links', 'quote', 'image', 'divider')),
|
|
||||||
text TEXT,
|
|
||||||
media TEXT,
|
|
||||||
href TEXT
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX content_blocks_owner_idx
|
|
||||||
ON content_blocks (owner_kind, owner_id, slot, sort_order);
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE content_block_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
detail TEXT,
|
|
||||||
url TEXT -- null → plain list item
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX content_block_items_block_idx
|
|
||||||
ON content_block_items (block_id, sort_order);
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- LINKS
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Entity-level links: a Register button, an Instagram handle, a
|
|
||||||
-- personal site. Distinct from links inside a content block, which
|
|
||||||
-- are part of a sentence rather than a control.
|
|
||||||
CREATE TABLE links (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner_kind TEXT NOT NULL
|
|
||||||
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
kind TEXT NOT NULL DEFAULT 'action'
|
|
||||||
CHECK (kind IN ('action', 'social', 'website', 'email')),
|
|
||||||
platform TEXT, -- 'instagram', 'discord'
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1))
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order);
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- FEEDBACK
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- The only table the public can write to.
|
|
||||||
--
|
|
||||||
-- page_path and section_id are free text rather than foreign keys
|
|
||||||
-- on purpose: they record where someone was when they wrote, and
|
|
||||||
-- that shouldn't change meaning when a route is later renamed.
|
|
||||||
CREATE TABLE feedback (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
|
|
||||||
feedback_type TEXT NOT NULL DEFAULT 'general',
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
name TEXT,
|
|
||||||
email TEXT,
|
|
||||||
|
|
||||||
page_path TEXT,
|
|
||||||
section_id TEXT,
|
|
||||||
|
|
||||||
status TEXT NOT NULL DEFAULT 'new'
|
|
||||||
CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')),
|
|
||||||
admin_note TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
ip_hash TEXT -- hashed, never the address
|
|
||||||
) STRICT;
|
|
||||||
|
|
||||||
CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC);
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- INTEGRITY FOR THE POLYMORPHIC TABLES
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TRIGGER content_blocks_owner_exists
|
|
||||||
BEFORE INSERT ON content_blocks
|
|
||||||
BEGIN
|
|
||||||
SELECT CASE
|
|
||||||
WHEN new.owner_kind = 'event'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'content_blocks: no such event')
|
|
||||||
WHEN new.owner_kind = 'organization'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'content_blocks: no such organization')
|
|
||||||
WHEN new.owner_kind = 'person'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'content_blocks: no such person')
|
|
||||||
WHEN new.owner_kind = 'team'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'content_blocks: no such team')
|
|
||||||
END;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER links_owner_exists
|
|
||||||
BEFORE INSERT ON links
|
|
||||||
BEGIN
|
|
||||||
SELECT CASE
|
|
||||||
WHEN new.owner_kind = 'event'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'links: no such event')
|
|
||||||
WHEN new.owner_kind = 'organization'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'links: no such organization')
|
|
||||||
WHEN new.owner_kind = 'person'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'links: no such person')
|
|
||||||
WHEN new.owner_kind = 'team'
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
|
|
||||||
THEN RAISE(ABORT, 'links: no such team')
|
|
||||||
END;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER organizations_cleanup
|
|
||||||
AFTER DELETE ON organizations
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id;
|
|
||||||
DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER events_cleanup
|
|
||||||
AFTER DELETE ON events
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id;
|
|
||||||
DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER people_cleanup
|
|
||||||
AFTER DELETE ON people
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id;
|
|
||||||
DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER teams_cleanup
|
|
||||||
AFTER DELETE ON teams
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
|
|
||||||
DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
|
|
||||||
-- ── updated_at ─────────────────────────────────────────────────
|
|
||||||
-- The WHEN guard stops the trigger recursing, and lets an explicit
|
|
||||||
-- updated_at through untouched, which matters when importing.
|
|
||||||
|
|
||||||
CREATE TRIGGER organizations_touch
|
|
||||||
AFTER UPDATE ON organizations
|
|
||||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
|
||||||
BEGIN
|
|
||||||
UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER events_touch
|
|
||||||
AFTER UPDATE ON events
|
|
||||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
|
||||||
BEGIN
|
|
||||||
UPDATE events SET updated_at = datetime('now') WHERE id = new.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER people_touch
|
|
||||||
AFTER UPDATE ON people
|
|
||||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
|
||||||
BEGIN
|
|
||||||
UPDATE people SET updated_at = datetime('now') WHERE id = new.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
-- VIEWS
|
|
||||||
-- ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Events with the host resolved and the logo/colour fallbacks
|
|
||||||
-- applied, so no handler has to remember the rules. An event with
|
|
||||||
-- no colour of its own inherits its host organization's; if that's
|
|
||||||
-- null too, the page applies the section default, which is where
|
|
||||||
-- that default lives.
|
|
||||||
CREATE VIEW v_events AS
|
|
||||||
SELECT
|
|
||||||
e.*,
|
|
||||||
o.name AS host_name,
|
|
||||||
o.kind AS host_kind,
|
|
||||||
o.logo AS host_logo,
|
|
||||||
COALESCE(e.org_logo, o.logo) AS effective_org_logo,
|
|
||||||
COALESCE(e.color, o.color) AS effective_color,
|
|
||||||
COALESCE(
|
|
||||||
e.status,
|
|
||||||
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
|
||||||
THEN 'past' ELSE 'upcoming' END
|
|
||||||
) AS effective_status
|
|
||||||
FROM events e
|
|
||||||
LEFT JOIN organizations o ON o.id = e.host_org_id;
|
|
||||||
|
|
||||||
|
|
||||||
-- Chapters flattened for the list. The API adds a map area to each
|
|
||||||
-- row using mapGrid.js; that can't happen here because the grid
|
|
||||||
-- isn't in this database. Leadership comes from v_org_leadership,
|
|
||||||
-- filtered on the chapter's id.
|
|
||||||
CREATE VIEW v_chapters AS
|
|
||||||
SELECT
|
|
||||||
o.id, o.name, o.short_name, o.tagline, o.color, o.logo,
|
|
||||||
o.venue, o.locality, o.state_code, o.country, o.location_label,
|
|
||||||
o.is_online, o.sort_order,
|
|
||||||
c.region_id, c.meets, c.started,
|
|
||||||
r.name AS region_name,
|
|
||||||
r.color AS region_color
|
|
||||||
FROM organizations o
|
|
||||||
JOIN chapters c ON c.id = o.id
|
|
||||||
LEFT JOIN organizations r ON r.id = c.region_id
|
|
||||||
WHERE o.is_published = 1;
|
|
||||||
|
|
||||||
|
|
||||||
-- Current, public leadership of any organization. Owners first,
|
|
||||||
-- then explicit order, then name. A chapter page, a region page and
|
|
||||||
-- the national Leadership page all read this; the only difference
|
|
||||||
-- is the org_id they filter on, and whether they group by team.
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
|
||||||
-- Every public affiliation a person holds, current or past. Feeds
|
|
||||||
-- the "affiliated organizations" block on a person's page, where
|
|
||||||
-- past roles are worth showing and v_org_leadership's current-only
|
|
||||||
-- filter would hide them.
|
|
||||||
CREATE VIEW v_person_affiliations AS
|
|
||||||
SELECT
|
|
||||||
a.person_id,
|
|
||||||
a.org_id,
|
|
||||||
a.team_id,
|
|
||||||
a.title,
|
|
||||||
a.role,
|
|
||||||
a.is_owner,
|
|
||||||
a.started_on,
|
|
||||||
a.ended_on,
|
|
||||||
(a.ended_on IS NULL) AS is_current,
|
|
||||||
a.sort_order,
|
|
||||||
o.name AS org_name,
|
|
||||||
o.kind AS org_kind,
|
|
||||||
o.logo AS org_logo,
|
|
||||||
o.color AS org_color,
|
|
||||||
t.name AS team_name
|
|
||||||
FROM affiliations a
|
|
||||||
JOIN organizations o ON o.id = a.org_id
|
|
||||||
LEFT JOIN teams t ON t.id = a.team_id
|
|
||||||
WHERE a.is_public = 1;
|
|
||||||
|
|
||||||
|
|
||||||
-- Public event billing only. Attendance rows stay out, because
|
|
||||||
-- is_public defaults to 0.
|
|
||||||
CREATE VIEW v_event_people AS
|
|
||||||
SELECT
|
|
||||||
ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order,
|
|
||||||
p.display_name, p.pronouns, p.tagline, p.photo
|
|
||||||
FROM event_people ep
|
|
||||||
JOIN people p ON p.id = ep.person_id AND p.is_published = 1
|
|
||||||
WHERE ep.is_public = 1;
|
|
||||||
1236
server/src/migrations/023_schema.sql
Normal file
1236
server/src/migrations/023_schema.sql
Normal file
File diff suppressed because it is too large
Load diff
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("editor"), async (c) => {
|
||||||
|
const entity = entityOr404(c);
|
||||||
|
const row = createRow(c.get("db"), entity, await json(c));
|
||||||
|
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
|
||||||
|
return c.json({ row }, 201, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
entities.patch("/:entity/:id", requireRole("editor"), 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("editor"), 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;
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
CONTENT ROUTES — read-only, mounted under /api
|
CONTENT ROUTES — read-only, mounted under /api
|
||||||
|
|
||||||
GET /events list + the section ids
|
GET /events list + the event scopes
|
||||||
GET /events/:id one event, full body, people
|
GET /events/:id one event, full body, people
|
||||||
GET /organizations list, ?kind=region|chapter|…
|
GET /organizations list, ?kind=region|chapter|…
|
||||||
GET /organizations/:id one organization's page
|
GET /organizations/:id one organization's page
|
||||||
|
GET /teams list, ?org=slug
|
||||||
|
GET /teams/:id one team's page
|
||||||
|
GET /awards list, ?org=slug
|
||||||
|
GET /awards/:id one award and its recipients
|
||||||
|
|
||||||
Organizations are one table, so they're one endpoint. A region
|
Organizations are one table, so they're one endpoint. A region
|
||||||
and a chapter differ by a handful of fields, which arrive under
|
and a chapter differ by a handful of fields, which arrive under
|
||||||
|
|
@ -12,14 +16,45 @@
|
||||||
list component be written once and pointed at any kind.
|
list component be written once and pointed at any kind.
|
||||||
|
|
||||||
Responses carry their fallbacks already resolved: an event's
|
Responses carry their fallbacks already resolved: an event's
|
||||||
`color` is its own or its host's, and `status` is derived from
|
`color` is its own or its first host's, and `status` is derived
|
||||||
the dates when it isn't set. Components read one field and don't
|
from the dates when it isn't set. Components read one field and
|
||||||
reimplement the rules.
|
don't reimplement the rules.
|
||||||
|
|
||||||
|
`event_type` is orthogonal to `scope_id`: the scope is whose
|
||||||
|
gathering it is (national, regional, partner…), the type is what
|
||||||
|
kind of gathering it is. A region can run a class and a partner
|
||||||
|
can run a retreat, so neither implies the other and both ship on
|
||||||
|
every event.
|
||||||
|
|
||||||
|
An event's hosts are a list, in billing order, and each one is
|
||||||
|
either an organization or a person — `kind` says which, and
|
||||||
|
`org_kind` is there for the three organization routes. The
|
||||||
|
first is the one the colour and logo fell back to, which is why
|
||||||
|
order is data and not a display choice.
|
||||||
|
|
||||||
|
Two things the team and award routes deliberately don't do:
|
||||||
|
|
||||||
|
· /teams/:id carries no roster. /teams/:id/people in people.js
|
||||||
|
already serves it off v_org_leadership in the shape
|
||||||
|
PeopleTiles wants, and a second shaper here would be the same
|
||||||
|
visibility rules written twice, free to drift.
|
||||||
|
|
||||||
|
· /awards/:id carries no links and no content blocks. 'award'
|
||||||
|
is not in the owner_kind CHECK on either polymorphic table,
|
||||||
|
and widening it is a STRICT table rebuild. `description` is
|
||||||
|
the prose; the recipients are the page.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
|
import {
|
||||||
|
asBool,
|
||||||
|
loadBlocks,
|
||||||
|
loadLinks,
|
||||||
|
paragraphs,
|
||||||
|
shapeSeries,
|
||||||
|
splitLinks,
|
||||||
|
} from "../shape.js";
|
||||||
|
|
||||||
const content = new Hono();
|
const content = new Hono();
|
||||||
|
|
||||||
|
|
@ -34,14 +69,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"];
|
||||||
|
|
||||||
const marks = (n) => Array(n).fill("?").join(",");
|
const marks = (n) => Array(n).fill("?").join(",");
|
||||||
|
|
||||||
|
/* ── Loaders ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Hosts for a batch of events, keyed by event id, in the order
|
||||||
|
they're billed. Shaped like loadLinks and loadBlocks so the list
|
||||||
|
route stays one query per collection rather than one per row.
|
||||||
|
|
||||||
|
Unpublished hosts are dropped here rather than in the view: the
|
||||||
|
admin reads the same view and needs to see them. An event whose
|
||||||
|
only host is unpublished comes back with an empty list, which is
|
||||||
|
the right answer — there is nobody to name and nowhere to link. */
|
||||||
|
function loadHosts(db, ids) {
|
||||||
|
const byEvent = new Map();
|
||||||
|
if (ids.length === 0) return byEvent;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT event_id, host_kind, host_id, host_name, host_org_kind
|
||||||
|
FROM v_event_hosts
|
||||||
|
WHERE event_id IN (${marks(ids.length)})
|
||||||
|
AND host_is_published = 1
|
||||||
|
ORDER BY event_id, sort_order, id`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byEvent.get(row.event_id) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
byEvent.set(row.event_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byEvent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shapers ───────────────────────────────────────────────── */
|
/* ── Shapers ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
function shapeEvent(row, links, cardBlocks) {
|
function shapeHost(row) {
|
||||||
|
return {
|
||||||
|
kind: row.host_kind, // 'organization' | 'person'
|
||||||
|
id: row.host_id,
|
||||||
|
name: row.host_name,
|
||||||
|
// Which of /regions, /chapters, /partners the slug belongs to.
|
||||||
|
// Null for a person, whose route needs no disambiguating.
|
||||||
|
org_kind: row.host_org_kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeEvent(row, links, cardBlocks, hosts = []) {
|
||||||
const { actions, instagram } = splitLinks(links);
|
const { actions, instagram } = splitLinks(links);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
section_id: row.section_id,
|
scope_id: row.scope_id,
|
||||||
|
event_type: row.event_type,
|
||||||
|
|
||||||
title: row.title,
|
title: row.title,
|
||||||
theme: row.theme,
|
theme: row.theme,
|
||||||
|
|
@ -51,6 +131,7 @@ function shapeEvent(row, links, cardBlocks) {
|
||||||
ends_on: row.ends_on,
|
ends_on: row.ends_on,
|
||||||
date_label: row.date_label,
|
date_label: row.date_label,
|
||||||
status: row.effective_status,
|
status: row.effective_status,
|
||||||
|
series: shapeSeries(row),
|
||||||
|
|
||||||
location_label: row.location_label,
|
location_label: row.location_label,
|
||||||
locality: row.locality,
|
locality: row.locality,
|
||||||
|
|
@ -63,9 +144,7 @@ function shapeEvent(row, links, cardBlocks) {
|
||||||
color: row.effective_color,
|
color: row.effective_color,
|
||||||
gradient: row.gradient,
|
gradient: row.gradient,
|
||||||
|
|
||||||
host: row.host_org_id
|
hosts: hosts.map(shapeHost),
|
||||||
? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
|
|
||||||
: null,
|
|
||||||
|
|
||||||
description: paragraphs(cardBlocks),
|
description: paragraphs(cardBlocks),
|
||||||
links: actions,
|
links: actions,
|
||||||
|
|
@ -124,6 +203,42 @@ function shapeLeader(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* teams.org_id is NOT NULL and every query below joins a published
|
||||||
|
organization, so `org` is never absent. */
|
||||||
|
function shapeTeam(row, links, cardBlocks) {
|
||||||
|
const { actions, socials, instagram } = splitLinks(links);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
tagline: row.tagline,
|
||||||
|
color: row.color,
|
||||||
|
logo: row.logo,
|
||||||
|
|
||||||
|
org: { id: row.org_id, name: row.org_name, kind: row.org_kind },
|
||||||
|
|
||||||
|
description: paragraphs(cardBlocks),
|
||||||
|
links: actions,
|
||||||
|
socials,
|
||||||
|
instagram,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* awards.org_id is nullable — an award can predate any decision
|
||||||
|
about which organization owns it — so `org` genuinely can be
|
||||||
|
null, and is also null when the awarding org is unpublished. */
|
||||||
|
function shapeAward(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
logo: row.logo,
|
||||||
|
org: row.org_id && row.org_name
|
||||||
|
? { id: row.org_id, name: row.org_name, kind: row.org_kind }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kind-specific details, batched ────────────────────────────
|
/* ── Kind-specific details, batched ────────────────────────────
|
||||||
Each of these runs a fixed number of queries for the whole list
|
Each of these runs a fixed number of queries for the whole list
|
||||||
rather than one per organization.
|
rather than one per organization.
|
||||||
|
|
@ -243,35 +358,125 @@ function attachLeadership(db, orgs) {
|
||||||
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
|
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Events ────────────────────────────────────────────────────
|
/* ── Sections that only an organization's own page wants ───────
|
||||||
Flat, with the section ids alongside. Retreats.tsx owns the
|
Called from /organizations/:id and not from the list. A page
|
||||||
section titles and colours and filters this list by section_id.
|
needs them; a card doesn't, and the listing shouldn't pay two
|
||||||
|
queries for something nothing renders.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Every team this organization has, including ones with nobody
|
||||||
|
currently filed under them. `leadership` already carries team_id
|
||||||
|
and team_name, so the page can group people without this — but
|
||||||
|
grouping alone would make an empty team invisible rather than
|
||||||
|
listed, which is the wrong answer for a team that exists. */
|
||||||
|
function attachTeams(db, orgs) {
|
||||||
|
const ids = orgs.map((o) => o.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, org_id, name, tagline, color, logo
|
||||||
|
FROM teams
|
||||||
|
WHERE org_id IN (${marks(ids.length)}) AND is_published = 1
|
||||||
|
ORDER BY sort_order, name`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
const byOrg = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byOrg.get(row.org_id);
|
||||||
|
const entry = {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
tagline: row.tagline,
|
||||||
|
color: row.color,
|
||||||
|
logo: row.logo,
|
||||||
|
};
|
||||||
|
if (list) list.push(entry);
|
||||||
|
else byOrg.set(row.org_id, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The awards this organization gives, drafts left out. */
|
||||||
|
function attachAwards(db, orgs) {
|
||||||
|
const ids = orgs.map((o) => o.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.org_id, a.name, a.description, a.logo,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
|
||||||
|
FROM awards a
|
||||||
|
WHERE a.org_id IN (${marks(ids.length)}) AND a.is_published = 1
|
||||||
|
ORDER BY a.sort_order, a.name`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
const byOrg = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byOrg.get(row.org_id);
|
||||||
|
const entry = {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
logo: row.logo,
|
||||||
|
recipient_count: row.recipient_count,
|
||||||
|
};
|
||||||
|
if (list) list.push(entry);
|
||||||
|
else byOrg.set(row.org_id, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const org of orgs) org.awards = byOrg.get(org.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Events ────────────────────────────────────────────────────
|
||||||
|
Flat, with the event scopes alongside. Retreats.tsx owns the
|
||||||
|
band titles and colours and filters this list by scope_id.
|
||||||
|
|
||||||
|
Oldest first, undated last. The carousel shows the list as it
|
||||||
|
comes and opens on the first upcoming event, so past events
|
||||||
|
have to sit before it for "previous" to go back in time; the
|
||||||
|
grid splits upcoming from past itself. There is no hand-typed
|
||||||
|
order; a tie falls to the title.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const EVENT_ORDER = `starts_on IS NULL, starts_on, title`;
|
||||||
|
|
||||||
content.get("/events", (c) => {
|
content.get("/events", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|
||||||
const sections = db
|
const scopes = db
|
||||||
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
|
.prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM v_events
|
`SELECT * FROM v_events
|
||||||
WHERE is_published = 1
|
WHERE is_published = 1
|
||||||
ORDER BY section_id, sort_order`,
|
ORDER BY ${EVENT_ORDER}`,
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const ids = rows.map((row) => row.id);
|
const ids = rows.map((row) => row.id);
|
||||||
const links = loadLinks(db, "event", ids);
|
const links = loadLinks(db, "event", ids);
|
||||||
const cards = loadBlocks(db, "event", ids, "card");
|
const cards = loadBlocks(db, "event", ids, "card");
|
||||||
|
const hosts = loadHosts(db, ids);
|
||||||
|
|
||||||
const events = rows.map((row) =>
|
const events = rows.map((row) =>
|
||||||
shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
|
shapeEvent(
|
||||||
|
row,
|
||||||
|
links.get(row.id) ?? [],
|
||||||
|
cards.get(row.id) ?? [],
|
||||||
|
hosts.get(row.id) ?? [],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return json(c, { sections, events });
|
return json(c, { scopes, events });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -289,6 +494,7 @@ content.get("/events/:id", (c) => {
|
||||||
const links = loadLinks(db, "event", [id]).get(id) ?? [];
|
const links = loadLinks(db, "event", [id]).get(id) ?? [];
|
||||||
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
|
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
|
||||||
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
|
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
|
||||||
|
const hosts = loadHosts(db, [id]).get(id) ?? [];
|
||||||
|
|
||||||
const people = db
|
const people = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -297,8 +503,36 @@ content.get("/events/:id", (c) => {
|
||||||
)
|
)
|
||||||
.all(id);
|
.all(id);
|
||||||
|
|
||||||
|
// Awards presented at this event. person_awards.event_id is the
|
||||||
|
// only thing that records where a citation was read out, and an
|
||||||
|
// event page is the one place it reads as news rather than
|
||||||
|
// trivia.
|
||||||
|
const awards = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT pa.award_id, pa.awarded_on, pa.citation,
|
||||||
|
a.name AS award_name, a.logo AS award_logo,
|
||||||
|
pa.person_id, p.display_name, p.photo
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.event_id = ? AND pa.is_public = 1
|
||||||
|
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
|
||||||
|
)
|
||||||
|
.all(id)
|
||||||
|
.map((r) => ({
|
||||||
|
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
|
||||||
|
person: { id: r.person_id, name: r.display_name, photo: r.photo },
|
||||||
|
awarded_on: r.awarded_on,
|
||||||
|
citation: r.citation,
|
||||||
|
}));
|
||||||
|
|
||||||
return json(c, {
|
return json(c, {
|
||||||
event: { ...shapeEvent(row, links, cards), blocks: body, people },
|
event: {
|
||||||
|
...shapeEvent(row, links, cards, hosts),
|
||||||
|
blocks: body,
|
||||||
|
people,
|
||||||
|
awards,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -379,19 +613,175 @@ content.get("/organizations/:id", (c) => {
|
||||||
attachRegionDetails(db, one);
|
attachRegionDetails(db, one);
|
||||||
attachChapterDetails(db, one);
|
attachChapterDetails(db, one);
|
||||||
attachLeadership(db, one);
|
attachLeadership(db, one);
|
||||||
|
attachTeams(db, one);
|
||||||
|
attachAwards(db, one);
|
||||||
|
|
||||||
// Everything this organization is hosting or has hosted.
|
// Everything this organization is hosting or has hosted, whether
|
||||||
|
// on its own or alongside somebody else. Co-hosting counts: an
|
||||||
|
// event run jointly by two regions belongs on both pages.
|
||||||
organization.events = db
|
organization.events = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, title, date_label, effective_status AS status,
|
`SELECT e.id, e.title, e.date_label, e.event_type,
|
||||||
location_label, event_logo, effective_color AS color
|
e.effective_status AS status,
|
||||||
FROM v_events
|
e.location_label, e.event_logo,
|
||||||
WHERE host_org_id = ? AND is_published = 1
|
e.effective_color AS color
|
||||||
ORDER BY sort_order`,
|
FROM v_events e
|
||||||
|
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
|
||||||
|
WHERE e.is_published = 1
|
||||||
|
ORDER BY ${EVENT_ORDER}`,
|
||||||
)
|
)
|
||||||
.all(id);
|
.all(id);
|
||||||
|
|
||||||
return json(c, { organization });
|
return json(c, { organization });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Teams ─────────────────────────────────────────────────────
|
||||||
|
GET /teams every published team
|
||||||
|
GET /teams?org=mid-atlantic one organization's teams
|
||||||
|
GET /teams/:id one team's page
|
||||||
|
|
||||||
|
An unpublished organization hides its teams too, in both
|
||||||
|
routes. Without that join a retired chapter's board stays
|
||||||
|
reachable by URL after the chapter itself has gone.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
content.get("/teams", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const org = c.req.query("org");
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM teams t
|
||||||
|
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
|
||||||
|
WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""}
|
||||||
|
ORDER BY o.sort_order, t.sort_order, t.name`,
|
||||||
|
)
|
||||||
|
.all(...(org ? [org] : []));
|
||||||
|
|
||||||
|
const ids = rows.map((row) => row.id);
|
||||||
|
const links = loadLinks(db, "team", ids);
|
||||||
|
const cards = loadBlocks(db, "team", ids, "card");
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
teams: rows.map((row) =>
|
||||||
|
shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
content.get("/teams/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM teams t
|
||||||
|
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
|
||||||
|
WHERE t.id = ? AND t.is_published = 1`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) return c.json({ error: "No such team" }, 404);
|
||||||
|
|
||||||
|
const links = loadLinks(db, "team", [id]).get(id) ?? [];
|
||||||
|
const cards = loadBlocks(db, "team", [id], "card").get(id) ?? [];
|
||||||
|
const body = loadBlocks(db, "team", [id], "body").get(id) ?? [];
|
||||||
|
|
||||||
|
return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Awards ────────────────────────────────────────────────────
|
||||||
|
GET /awards every award
|
||||||
|
GET /awards?org=ngu awards a given organization gives
|
||||||
|
GET /awards/:id one award and who has received it
|
||||||
|
|
||||||
|
An unpublished award is a draft: left out of the list, and a
|
||||||
|
404 at its own URL, the same as any other unpublished row.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
content.get("/awards", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const org = c.req.query("org");
|
||||||
|
|
||||||
|
// The count has to apply exactly the visibility rules the detail
|
||||||
|
// route does, or a card will promise recipients the page then
|
||||||
|
// doesn't list.
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.*, o.name AS org_name, o.kind AS org_kind,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
|
||||||
|
FROM awards a
|
||||||
|
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
|
||||||
|
WHERE a.is_published = 1 ${org ? "AND a.org_id = ?" : ""}
|
||||||
|
ORDER BY a.sort_order, a.name`,
|
||||||
|
)
|
||||||
|
.all(...(org ? [org] : []));
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
awards: rows.map((row) => ({
|
||||||
|
...shapeAward(row),
|
||||||
|
recipient_count: row.recipient_count,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
content.get("/awards/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
|
||||||
|
FROM awards a
|
||||||
|
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
|
||||||
|
WHERE a.id = ? AND a.is_published = 1`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) return c.json({ error: "No such award" }, 404);
|
||||||
|
|
||||||
|
// The event join is LEFT twice over: person_awards.event_id is
|
||||||
|
// ON DELETE SET NULL, and the event may since have been
|
||||||
|
// unpublished. A citation outlives the occasion it was read at.
|
||||||
|
//
|
||||||
|
// awarded_on DESC puts undated rows last in SQLite, which is the
|
||||||
|
// right end for a recipient nobody has dated yet.
|
||||||
|
const recipients = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT pa.person_id, pa.awarded_on, pa.citation,
|
||||||
|
p.display_name, p.photo, p.tagline,
|
||||||
|
e.id AS event_id, e.title AS event_title
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
|
||||||
|
WHERE pa.award_id = ? AND pa.is_public = 1
|
||||||
|
ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`,
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
award: {
|
||||||
|
...shapeAward(row),
|
||||||
|
recipients: recipients.map((r) => ({
|
||||||
|
id: r.person_id,
|
||||||
|
name: r.display_name,
|
||||||
|
photo: r.photo,
|
||||||
|
tagline: r.tagline,
|
||||||
|
awarded_on: r.awarded_on,
|
||||||
|
citation: r.citation,
|
||||||
|
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default content;
|
export default content;
|
||||||
|
|
|
||||||
217
server/src/routes/history.js
Normal file
217
server/src/routes/history.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HISTORY ROUTE — read-only, mounted under /api
|
||||||
|
|
||||||
|
GET /history every published timeline entry
|
||||||
|
|
||||||
|
v_timeline has already done the resolution: an entry with no title
|
||||||
|
of its own carries the referenced record's name, an entry with no
|
||||||
|
date carries the event's starts_on, and org_kind rides along
|
||||||
|
because /regions, /chapters and /partners are three routes and only
|
||||||
|
the database knows which a slug is.
|
||||||
|
|
||||||
|
What's left here is shaping, and three things the view can't do:
|
||||||
|
|
||||||
|
· rosters. A 'people' entry naming a team resolves through
|
||||||
|
v_org_leadership; one with an editorial list reads
|
||||||
|
timeline_entry_people. Both are batched, so the number of
|
||||||
|
queries doesn't grow with the number of entries.
|
||||||
|
|
||||||
|
· precision that outruns the date. An entry can hold '2012' with
|
||||||
|
precision 'day' — the admin doesn't stop you. Trusting that
|
||||||
|
pair would put the entry in a month node built from a month
|
||||||
|
that isn't there, so precision is capped at what the string
|
||||||
|
actually carries.
|
||||||
|
|
||||||
|
· entries with no date at all. They can't be placed on a rail, so
|
||||||
|
they're dropped rather than crashing the page, and counted so
|
||||||
|
the omission is visible rather than silent.
|
||||||
|
|
||||||
|
Filenames only, as everywhere else in this API. Where the images
|
||||||
|
live is the component's business.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
const history = new Hono();
|
||||||
|
|
||||||
|
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||||
|
|
||||||
|
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||||
|
|
||||||
|
const marks = (n) => Array(n).fill("?").join(",");
|
||||||
|
|
||||||
|
/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */
|
||||||
|
function precisionOfString(date) {
|
||||||
|
const parts = String(date).split("-");
|
||||||
|
if (parts.length >= 3) return "day";
|
||||||
|
if (parts.length === 2) return "month";
|
||||||
|
return "year";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK = { year: 0, month: 1, day: 2 };
|
||||||
|
|
||||||
|
/* The stored precision is a claim about how much to trust the date. It
|
||||||
|
can't be more precise than the date itself, and a row that claims
|
||||||
|
otherwise is a data error the page shouldn't have to survive. */
|
||||||
|
function effectivePrecision(stored, date) {
|
||||||
|
const actual = precisionOfString(date);
|
||||||
|
return RANK[stored] < RANK[actual] ? stored : actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeEntry(row, rosters) {
|
||||||
|
const precision = effectivePrecision(row.precision, row.effective_date);
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: String(row.id),
|
||||||
|
date: row.effective_date,
|
||||||
|
precision,
|
||||||
|
kind: row.kind,
|
||||||
|
title: row.effective_title,
|
||||||
|
featured: row.is_featured === 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row.effective_blurb) item.blurb = row.effective_blurb;
|
||||||
|
if (row.meta) item.meta = row.meta;
|
||||||
|
|
||||||
|
// An explicit link wins on the client too, but sending it only when
|
||||||
|
// set keeps "no override" distinguishable from "override to empty".
|
||||||
|
if (row.link_url) item.href = row.link_url;
|
||||||
|
|
||||||
|
if (row.ref_kind && row.ref_id) {
|
||||||
|
item.ref = { kind: row.ref_kind, id: row.ref_id };
|
||||||
|
// Only organizations need it, and only they have it.
|
||||||
|
if (row.org_kind) item.ref.orgKind = row.org_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.effective_logo && row.ref_kind) {
|
||||||
|
item.logo = { file: row.effective_logo, kind: row.ref_kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ref_kind === "team") {
|
||||||
|
item.team = {
|
||||||
|
id: row.ref_id,
|
||||||
|
name: row.team_name,
|
||||||
|
orgId: row.team_org_id ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = rosters.get(row.id);
|
||||||
|
if (people?.length) item.people = people;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.get("/history", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM v_timeline
|
||||||
|
WHERE is_published = 1
|
||||||
|
-- A standalone milestone reports null here and is unaffected.
|
||||||
|
AND (ref_is_published IS NULL OR ref_is_published = 1)
|
||||||
|
AND effective_date IS NOT NULL
|
||||||
|
ORDER BY effective_date DESC, sort_order, id`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// How many entries exist but can't be placed. Worth knowing about —
|
||||||
|
// an entry nobody gave a date to is invisible, and silence is how it
|
||||||
|
// stays that way.
|
||||||
|
const undated = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM v_timeline
|
||||||
|
WHERE is_published = 1 AND effective_date IS NULL`,
|
||||||
|
)
|
||||||
|
.get().n;
|
||||||
|
|
||||||
|
const rosters = loadRosters(db, rows);
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
items: rows.map((row) => shapeEntry(row, rosters)),
|
||||||
|
undated,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Rosters ───────────────────────────────────────────────────
|
||||||
|
Two queries total, whatever the number of entries. A team entry
|
||||||
|
reads the team's current public membership; anything else reads
|
||||||
|
the entry's own curated list.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function loadRosters(db, rows) {
|
||||||
|
const rosters = new Map();
|
||||||
|
|
||||||
|
const teamEntries = rows.filter(
|
||||||
|
(row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id,
|
||||||
|
);
|
||||||
|
const listEntries = rows.filter(
|
||||||
|
(row) => row.kind === "people" && row.ref_kind !== "team",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (teamEntries.length) {
|
||||||
|
const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))];
|
||||||
|
|
||||||
|
// v_org_leadership already decides who counts as current and
|
||||||
|
// public — affiliation still open, marked public, person
|
||||||
|
// published. Restating those conditions here is how they drift.
|
||||||
|
const members = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT team_id, person_id, display_name, photo, title
|
||||||
|
FROM v_org_leadership
|
||||||
|
WHERE team_id IN (${marks(teamIds.length)})
|
||||||
|
ORDER BY is_owner DESC, sort_order,
|
||||||
|
COALESCE(sort_name, display_name)`,
|
||||||
|
)
|
||||||
|
.all(...teamIds);
|
||||||
|
|
||||||
|
const byTeam = new Map();
|
||||||
|
for (const member of members) {
|
||||||
|
const list = byTeam.get(member.team_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: member.person_id,
|
||||||
|
name: member.display_name,
|
||||||
|
...(member.photo ? { photo: member.photo } : {}),
|
||||||
|
...(member.title ? { title: member.title } : {}),
|
||||||
|
});
|
||||||
|
byTeam.set(member.team_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of teamEntries) {
|
||||||
|
const list = byTeam.get(row.ref_id);
|
||||||
|
if (list) rosters.set(row.id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listEntries.length) {
|
||||||
|
const ids = listEntries.map((row) => row.id);
|
||||||
|
|
||||||
|
const listed = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT tep.entry_id, tep.person_id, tep.note,
|
||||||
|
p.display_name, p.photo
|
||||||
|
FROM timeline_entry_people tep
|
||||||
|
JOIN people p ON p.id = tep.person_id AND p.is_published = 1
|
||||||
|
WHERE tep.entry_id IN (${marks(ids.length)})
|
||||||
|
ORDER BY tep.entry_id, tep.sort_order`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
|
||||||
|
for (const person of listed) {
|
||||||
|
const list = rosters.get(person.entry_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: person.person_id,
|
||||||
|
name: person.display_name,
|
||||||
|
...(person.photo ? { photo: person.photo } : {}),
|
||||||
|
// The note is the person's standing in this entry, which is
|
||||||
|
// what `title` means on the client.
|
||||||
|
...(person.note ? { title: person.note } : {}),
|
||||||
|
});
|
||||||
|
rosters.set(person.entry_id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rosters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default history;
|
||||||
186
server/src/routes/home.js
Normal file
186
server/src/routes/home.js
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FRONT PAGE ROUTE — read-only, mounted under /api
|
||||||
|
|
||||||
|
GET /front-page the home page's configuration, resolved
|
||||||
|
|
||||||
|
Everything the admin's Front page editor holds, shaped for the
|
||||||
|
page: hidden sections dropped, stats counted, paths carrying
|
||||||
|
their actions, and the countdown's event looked up.
|
||||||
|
|
||||||
|
The retreats carousel and the timeline rail are not in here.
|
||||||
|
They fetch /events and /history themselves, as they do on their
|
||||||
|
own pages, so the rules for which events and entries are public
|
||||||
|
live in one place each. This route only says whether those bands
|
||||||
|
appear and under what heading.
|
||||||
|
|
||||||
|
── Stats ──
|
||||||
|
A stat's source picks a query from STAT_QUERIES. Each counts
|
||||||
|
exactly what the matching public page shows: published rows, and
|
||||||
|
for awards only public citations to published people. A count
|
||||||
|
that disagreed with the page it summarises would be worse than
|
||||||
|
none. 'manual' and 'years_since' read the row's own value.
|
||||||
|
|
||||||
|
── Countdown ──
|
||||||
|
The pinned event if it is still published and not over;
|
||||||
|
otherwise the next published, non-cancelled event that hasn't
|
||||||
|
ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today,
|
||||||
|
so a running series with a start date in the past still counts.
|
||||||
|
The client works out the next meeting of a series from `series`.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { asBool, shapeSeries } from "../shape.js";
|
||||||
|
|
||||||
|
const home = new Hono();
|
||||||
|
|
||||||
|
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||||
|
|
||||||
|
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||||
|
|
||||||
|
const PAGE_ID = "home";
|
||||||
|
|
||||||
|
const STAT_QUERIES = {
|
||||||
|
regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`,
|
||||||
|
chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`,
|
||||||
|
partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`,
|
||||||
|
events_held: `SELECT COUNT(*) AS n FROM v_events
|
||||||
|
WHERE is_published = 1 AND effective_status = 'past'`,
|
||||||
|
retreats_held: `SELECT COUNT(*) AS n FROM v_events
|
||||||
|
WHERE is_published = 1 AND effective_status = 'past'
|
||||||
|
AND event_type = 'retreat'`,
|
||||||
|
people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`,
|
||||||
|
awards_given: `SELECT COUNT(*) AS n
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
|
||||||
|
WHERE pa.is_public = 1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The number as a string, or null when there's nothing to print —
|
||||||
|
a manual stat nobody filled in, or a year that isn't one. */
|
||||||
|
function statValue(db, row) {
|
||||||
|
if (row.source === "manual") return row.value || null;
|
||||||
|
|
||||||
|
if (row.source === "years_since") {
|
||||||
|
const year = Number.parseInt(row.value ?? "", 10);
|
||||||
|
if (!Number.isInteger(year)) return null;
|
||||||
|
return String(Math.max(0, new Date().getFullYear() - year));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = STAT_QUERIES[row.source];
|
||||||
|
return sql ? String(db.prepare(sql).get().n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeCountdown(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
theme: row.theme,
|
||||||
|
starts_on: row.starts_on,
|
||||||
|
ends_on: row.ends_on,
|
||||||
|
date_label: row.date_label,
|
||||||
|
location_label: row.location_label,
|
||||||
|
is_online: asBool(row.is_online),
|
||||||
|
color: row.effective_color,
|
||||||
|
event_logo: row.event_logo,
|
||||||
|
series: shapeSeries(row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
home.get("/front-page", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
|
||||||
|
|
||||||
|
// The schema seeds the row and the engine refuses to delete it,
|
||||||
|
// so this is a database that hasn't been migrated. Say so.
|
||||||
|
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
|
||||||
|
|
||||||
|
const byOrder = (table) =>
|
||||||
|
db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID);
|
||||||
|
|
||||||
|
const sections = byOrder("front_page_sections")
|
||||||
|
.filter((row) => !asBool(row.is_hidden))
|
||||||
|
.map((row) => ({ section: row.section, title: row.title, blurb: row.blurb }));
|
||||||
|
|
||||||
|
const slides = byOrder("front_page_slides").map((row) => ({
|
||||||
|
media: row.media,
|
||||||
|
alt: row.alt,
|
||||||
|
caption: row.caption,
|
||||||
|
link_url: row.link_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stats = byOrder("front_page_stats")
|
||||||
|
.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
value: statValue(db, row),
|
||||||
|
suffix: row.suffix,
|
||||||
|
note: row.note,
|
||||||
|
}))
|
||||||
|
.filter((stat) => stat.value !== null);
|
||||||
|
|
||||||
|
const actions = db.prepare(
|
||||||
|
`SELECT label, description, url FROM front_page_path_actions
|
||||||
|
WHERE path_id = ? ORDER BY sort_order`,
|
||||||
|
);
|
||||||
|
const paths = byOrder("front_page_paths")
|
||||||
|
.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
icon: row.icon,
|
||||||
|
blurb: row.blurb,
|
||||||
|
actions: actions.all(row.id),
|
||||||
|
}))
|
||||||
|
// A path with nothing to do is a dead tab.
|
||||||
|
.filter((path) => path.actions.length > 0);
|
||||||
|
|
||||||
|
const notOver = `is_published = 1
|
||||||
|
AND effective_status != 'cancelled'
|
||||||
|
AND COALESCE(ends_on, starts_on) >= date('now')`;
|
||||||
|
|
||||||
|
const pinned = page.countdown_event_id
|
||||||
|
? db
|
||||||
|
.prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`)
|
||||||
|
.get(page.countdown_event_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const next =
|
||||||
|
pinned ??
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM v_events
|
||||||
|
WHERE ${notOver}
|
||||||
|
ORDER BY starts_on, title
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
front_page: {
|
||||||
|
hero: {
|
||||||
|
mode: page.hero_mode,
|
||||||
|
eyebrow: page.eyebrow,
|
||||||
|
headline: page.headline,
|
||||||
|
subhead: page.subhead,
|
||||||
|
primary: page.primary_label && page.primary_url
|
||||||
|
? { label: page.primary_label, url: page.primary_url }
|
||||||
|
: null,
|
||||||
|
secondary: page.secondary_label && page.secondary_url
|
||||||
|
? { label: page.secondary_label, url: page.secondary_url }
|
||||||
|
: null,
|
||||||
|
slide_seconds: page.slide_seconds,
|
||||||
|
slides,
|
||||||
|
livestream: page.livestream_url
|
||||||
|
? { url: page.livestream_url, title: page.livestream_title }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
stats,
|
||||||
|
paths,
|
||||||
|
countdown: shapeCountdown(next),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default home;
|
||||||
215
server/src/routes/panel.js
Normal file
215
server/src/routes/panel.js
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PANEL ROUTES — server/src/routes/panel.js
|
||||||
|
|
||||||
|
GET /api/admin/panel/overview
|
||||||
|
PATCH /api/admin/panel/users/:id role, is_active
|
||||||
|
DELETE /api/admin/panel/users/:id/sessions sign out everywhere
|
||||||
|
|
||||||
|
Everything here is superadmin-only, enforced once at the top
|
||||||
|
rather than per route — there's no read here that an ordinary
|
||||||
|
admin should have either. Account records and live session
|
||||||
|
counts are a different class of thing from content.
|
||||||
|
|
||||||
|
Two rules run through the writes, both about not locking
|
||||||
|
everyone out of the building:
|
||||||
|
|
||||||
|
* nobody edits their own role or active flag, so a misclick
|
||||||
|
can't demote the person making it;
|
||||||
|
* the last active superadmin can't be demoted or disabled.
|
||||||
|
|
||||||
|
Changing a role or disabling an account drops that person's
|
||||||
|
live sessions immediately, the same way admin-cli.js does.
|
||||||
|
Leaving a 30-day cookie valid after revoking the access it
|
||||||
|
represents is the whole point of having the button.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { requireAuth, requireRole, ROLES } from "../auth.js";
|
||||||
|
|
||||||
|
const panel = new Hono();
|
||||||
|
|
||||||
|
panel.use("*", requireAuth);
|
||||||
|
panel.use("*", requireRole("superadmin"));
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
/* The counts on the overview. Table name is a literal from this
|
||||||
|
list, never anything off the wire. */
|
||||||
|
const CONTENT_TABLES = [
|
||||||
|
["Events", "events"],
|
||||||
|
["Organizations", "organizations"],
|
||||||
|
["People", "people"],
|
||||||
|
["Teams", "teams"],
|
||||||
|
["Awards", "awards"],
|
||||||
|
["Timeline entries", "timeline_entries"],
|
||||||
|
["Feedback", "feedback"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── GET /api/admin/panel/overview ─────────────────────────────── */
|
||||||
|
|
||||||
|
panel.get("/overview", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const users = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.email, u.name, u.role, u.is_active,
|
||||||
|
u.created_at, u.last_login_at,
|
||||||
|
(SELECT COUNT(*) FROM sessions s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
AND s.expires_at > datetime('now')) AS sessions
|
||||||
|
FROM admin_users u
|
||||||
|
ORDER BY u.role DESC, u.email`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const content = CONTENT_TABLES.map(([label, table]) => ({
|
||||||
|
label,
|
||||||
|
count: count(db, table),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
system: {
|
||||||
|
schemaVersion: db.prepare("PRAGMA user_version").get().user_version,
|
||||||
|
dbPath: process.env.DB_PATH ?? null,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
platform: `${process.platform} ${process.arch}`,
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
|
||||||
|
sessions: count(db, "sessions", "expires_at > datetime('now')"),
|
||||||
|
roles: ROLES,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
users,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
NO_STORE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* A table that hasn't been created yet shouldn't take the whole
|
||||||
|
page down — the panel is where you go when something is wrong. */
|
||||||
|
function count(db, table, where) {
|
||||||
|
try {
|
||||||
|
const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`;
|
||||||
|
return db.prepare(sql).get().n;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */
|
||||||
|
|
||||||
|
panel.patch("/users/:id", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const me = c.get("user");
|
||||||
|
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
if (id === me.id) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "You can't change your own role or access. Ask another superadmin." },
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Expected a JSON body." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = db
|
||||||
|
.prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?")
|
||||||
|
.get(id);
|
||||||
|
if (!target) return c.json({ error: "No such account." }, 404);
|
||||||
|
|
||||||
|
const sets = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const losingSuper =
|
||||||
|
target.role === "superadmin" &&
|
||||||
|
((body.role !== undefined && body.role !== "superadmin") ||
|
||||||
|
(body.is_active !== undefined && Number(body.is_active) === 0));
|
||||||
|
|
||||||
|
if (losingSuper && activeSupers(db) <= 1) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "That's the last active superadmin. Promote someone else first." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.role !== undefined) {
|
||||||
|
if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422);
|
||||||
|
sets.push("role = ?");
|
||||||
|
params.push(body.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.is_active !== undefined) {
|
||||||
|
sets.push("is_active = ?");
|
||||||
|
params.push(Number(body.is_active) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run(
|
||||||
|
...params,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
// Whatever changed, the access they're holding no longer
|
||||||
|
// matches the row. Make them sign in again.
|
||||||
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ user: userRow(db, id) }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */
|
||||||
|
|
||||||
|
panel.delete("/users/:id/sessions", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
|
||||||
|
|
||||||
|
const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id);
|
||||||
|
if (!target) return c.json({ error: "No such account." }, 404);
|
||||||
|
|
||||||
|
const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeSupers(db) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1",
|
||||||
|
)
|
||||||
|
.get().n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRow(db, id) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.email, u.name, u.role, u.is_active,
|
||||||
|
u.created_at, u.last_login_at,
|
||||||
|
(SELECT COUNT(*) FROM sessions s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
AND s.expires_at > datetime('now')) AS sessions
|
||||||
|
FROM admin_users u WHERE u.id = ?`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default panel;
|
||||||
299
server/src/routes/people.js
Normal file
299
server/src/routes/people.js
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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
|
||||||
|
GET /people/:id one person's page
|
||||||
|
|
||||||
|
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, loadBlocks, loadLinks, paragraphs, splitLinks } 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) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── One person's page ─────────────────────────────────────────
|
||||||
|
Everything public that points at this person, each list with
|
||||||
|
the same visibility rules its own page applies: a role needs a
|
||||||
|
public affiliation and a published organization, an event must
|
||||||
|
be published, an award must be published and the citation
|
||||||
|
public. A hidden team drops its name rather than the role —
|
||||||
|
the seat is still real, it just has no page to link to.
|
||||||
|
|
||||||
|
Roles are current and past. v_org_leadership only knows
|
||||||
|
current, which is what a roster wants and not what a person's
|
||||||
|
record does, so this reads affiliations directly.
|
||||||
|
|
||||||
|
Events merge two tables: event_people (who was billed, and as
|
||||||
|
what) and event_hosts (who ran it). One person can be both at
|
||||||
|
one event, so they collapse to one row carrying every role.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
people.get("/people/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const row = 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,
|
||||||
|
o.kind AS primary_org_kind
|
||||||
|
FROM people p
|
||||||
|
LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1
|
||||||
|
WHERE p.id = ? AND p.is_published = 1`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!row) return c.json({ error: "No such person" }, 404);
|
||||||
|
|
||||||
|
const links = loadLinks(db, "person", [id]).get(id) ?? [];
|
||||||
|
const cards = loadBlocks(db, "person", [id], "card").get(id) ?? [];
|
||||||
|
const body = loadBlocks(db, "person", [id], "body").get(id) ?? [];
|
||||||
|
const { actions, socials, website, instagram } = splitLinks(links);
|
||||||
|
|
||||||
|
// Current first, then most recently ended. Within each, the same
|
||||||
|
// order a roster uses: owner, then the affiliation's sort_order.
|
||||||
|
const roles = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on,
|
||||||
|
o.id AS org_id, o.name AS org_name, o.kind AS org_kind,
|
||||||
|
t.id AS team_id, t.name AS team_name
|
||||||
|
FROM affiliations a
|
||||||
|
JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
|
||||||
|
LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1
|
||||||
|
WHERE a.person_id = ? AND a.is_public = 1
|
||||||
|
ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC,
|
||||||
|
a.is_owner DESC, a.sort_order, o.sort_order`,
|
||||||
|
)
|
||||||
|
.all(id)
|
||||||
|
.map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
role: r.role,
|
||||||
|
is_owner: asBool(r.is_owner),
|
||||||
|
started_on: r.started_on,
|
||||||
|
ended_on: r.ended_on,
|
||||||
|
org: { id: r.org_id, name: r.org_name, kind: r.org_kind },
|
||||||
|
team: r.team_id ? { id: r.team_id, name: r.team_name } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const eventRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on,
|
||||||
|
e.effective_status AS status, x.role, x.title AS billing
|
||||||
|
FROM (
|
||||||
|
SELECT event_id, role, title, sort_order
|
||||||
|
FROM event_people
|
||||||
|
WHERE person_id = ? AND is_public = 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT event_id, 'host', NULL, -1
|
||||||
|
FROM event_hosts
|
||||||
|
WHERE person_id = ?
|
||||||
|
) x
|
||||||
|
JOIN v_events e ON e.id = x.event_id AND e.is_published = 1
|
||||||
|
ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`,
|
||||||
|
)
|
||||||
|
.all(id, id);
|
||||||
|
|
||||||
|
const byEvent = new Map();
|
||||||
|
for (const r of eventRows) {
|
||||||
|
let event = byEvent.get(r.id);
|
||||||
|
if (!event) {
|
||||||
|
event = {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
event_type: r.event_type,
|
||||||
|
date_label: r.date_label,
|
||||||
|
starts_on: r.starts_on,
|
||||||
|
status: r.status,
|
||||||
|
roles: [],
|
||||||
|
};
|
||||||
|
byEvent.set(r.id, event);
|
||||||
|
}
|
||||||
|
// Hosting shows up from both tables when a host is also billed
|
||||||
|
// as one. Once is enough.
|
||||||
|
if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) {
|
||||||
|
event.roles.push({ role: r.role, title: r.billing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const awards = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT pa.awarded_on, pa.citation,
|
||||||
|
a.id AS award_id, a.name AS award_name, a.logo AS award_logo,
|
||||||
|
e.id AS event_id, e.title AS event_title
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
|
||||||
|
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
|
||||||
|
WHERE pa.person_id = ? AND pa.is_public = 1
|
||||||
|
ORDER BY pa.awarded_on DESC, a.sort_order, a.name`,
|
||||||
|
)
|
||||||
|
.all(id)
|
||||||
|
.map((r) => ({
|
||||||
|
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
|
||||||
|
awarded_on: r.awarded_on,
|
||||||
|
citation: r.citation,
|
||||||
|
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const person = shapePerson(row);
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
person: {
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
pronouns: person.pronouns,
|
||||||
|
tagline: person.tagline,
|
||||||
|
photo: person.photo,
|
||||||
|
location_label: person.location_label,
|
||||||
|
public_email: person.public_email,
|
||||||
|
org: person.org && { ...person.org, kind: row.primary_org_kind },
|
||||||
|
bio: person.bio ?? [],
|
||||||
|
description: paragraphs(cards),
|
||||||
|
blocks: body,
|
||||||
|
links: actions,
|
||||||
|
socials,
|
||||||
|
website,
|
||||||
|
instagram,
|
||||||
|
roles,
|
||||||
|
events: [...byEvent.values()],
|
||||||
|
awards,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default people;
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
|
||||||
SEED
|
|
||||||
|
|
||||||
Reads the two static data modules and fills the database from
|
|
||||||
them. Run once to make the move, and re-runnable after you tweak
|
|
||||||
the source files.
|
|
||||||
|
|
||||||
cd /root/NGU-Web.v1.3-sqlite/server
|
|
||||||
DB_PATH=./dev.db node src/seed.js
|
|
||||||
|
|
||||||
Run it from the repo, not from /srv/ngu-api — the deployed copy
|
|
||||||
has no src/data to read.
|
|
||||||
|
|
||||||
⚠ It clears every content table first, so anything typed
|
|
||||||
straight into the database is lost. Feedback is never touched.
|
|
||||||
|
|
||||||
Section presentation (title, accent, background, defaultView) is
|
|
||||||
NOT imported. Retreats.jsx owns that; only the ids come across,
|
|
||||||
so section_id has something real to reference.
|
|
||||||
|
|
||||||
Three things it deliberately does NOT do, each flagged in the
|
|
||||||
warnings at the end rather than guessed at:
|
|
||||||
|
|
||||||
dates "March/April 2026" isn't parseable, and half-right
|
|
||||||
dates are worse than none. starts_on stays null and
|
|
||||||
the explicit status carries the upcoming/past split
|
|
||||||
exactly as it does today.
|
|
||||||
|
|
||||||
partners the five partner events are placeholders with no
|
|
||||||
organization behind them, so host_org_id is null.
|
|
||||||
|
|
||||||
leads "Chapter lead name" is not a person. Inventing a
|
|
||||||
people row from a placeholder string would put a
|
|
||||||
fake name on the site.
|
|
||||||
═══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import { openDatabase, migrate, tx } from "./db.js";
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH ?? "./dev.db";
|
|
||||||
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js";
|
|
||||||
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js";
|
|
||||||
|
|
||||||
// The root organization. Every national retreat hangs off this, and
|
|
||||||
// it's what makes the org_logo fallback work uniformly.
|
|
||||||
const NGU = {
|
|
||||||
id: "ngu",
|
|
||||||
name: "Next Generation of Unity",
|
|
||||||
short_name: "NGU",
|
|
||||||
color: "#138ba0",
|
|
||||||
logo: "ngu-logo-white-bg.svg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const warnings = [];
|
|
||||||
const warn = (message) => warnings.push(message);
|
|
||||||
|
|
||||||
/* ── Load the source modules ───────────────────────────────── */
|
|
||||||
|
|
||||||
async function load(relative) {
|
|
||||||
const path = resolve(HERE, relative);
|
|
||||||
try {
|
|
||||||
return await import(pathToFileURL(path).href);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`\nCould not read ${path}`);
|
|
||||||
console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventsModule = await load(EVENTS_MODULE);
|
|
||||||
const chaptersModule = await load(CHAPTERS_MODULE);
|
|
||||||
|
|
||||||
const eventsData = eventsModule.default;
|
|
||||||
const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule;
|
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const isStateCode = (code) =>
|
|
||||||
Boolean(code) && code !== "CANADA" && code in STATE_NAMES;
|
|
||||||
|
|
||||||
const opposite = (edge) => (edge === "top" ? "bottom" : "top");
|
|
||||||
|
|
||||||
const instagramUrl = (handle) =>
|
|
||||||
`https://instagram.com/${String(handle).replace(/^@/, "")}`;
|
|
||||||
|
|
||||||
// "Unity Village, MO" → { locality, state_code }. Anything that
|
|
||||||
// doesn't end in a real state code keeps the whole string as the
|
|
||||||
// locality, and location_label carries the original either way.
|
|
||||||
function splitPlace(label) {
|
|
||||||
if (!label) return { locality: null, state_code: null };
|
|
||||||
|
|
||||||
const comma = label.lastIndexOf(",");
|
|
||||||
if (comma === -1) return { locality: label.trim(), state_code: null };
|
|
||||||
|
|
||||||
const head = label.slice(0, comma).trim();
|
|
||||||
const tail = label.slice(comma + 1).trim();
|
|
||||||
|
|
||||||
return isStateCode(tail)
|
|
||||||
? { locality: head, state_code: tail }
|
|
||||||
: { locality: label.trim(), state_code: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function chapterLocation(chapter) {
|
|
||||||
const online = chapter.state === null && !chapter.city?.includes(",");
|
|
||||||
if (online || /^online$/i.test(chapter.city ?? "")) {
|
|
||||||
return {
|
|
||||||
locality: null, state_code: null, country: "US",
|
|
||||||
location_label: chapter.city ?? "Online", is_online: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapter.state === "CANADA") {
|
|
||||||
return {
|
|
||||||
locality: splitPlace(chapter.city).locality,
|
|
||||||
state_code: null, country: "CA",
|
|
||||||
location_label: chapter.city, is_online: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { locality } = splitPlace(chapter.city);
|
|
||||||
return {
|
|
||||||
locality,
|
|
||||||
state_code: isStateCode(chapter.state) ? chapter.state : null,
|
|
||||||
country: "US",
|
|
||||||
location_label: chapter.city,
|
|
||||||
is_online: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventLocation(label) {
|
|
||||||
if (!label || /^online$/i.test(label)) {
|
|
||||||
return {
|
|
||||||
locality: null, state_code: null, country: "US",
|
|
||||||
location_label: label ?? null, is_online: label ? 1 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const { locality, state_code } = splitPlace(label);
|
|
||||||
return { locality, state_code, country: "US", location_label: label, is_online: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Open ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const db = await openDatabase(DB_PATH);
|
|
||||||
migrate(db, { log: () => {} });
|
|
||||||
|
|
||||||
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
||||||
if (version < 2) {
|
|
||||||
throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Statements ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const ins = {
|
|
||||||
org: db.prepare(`
|
|
||||||
INSERT INTO organizations
|
|
||||||
(id, kind, name, short_name, tagline, color, logo,
|
|
||||||
venue, locality, state_code, country, location_label, is_online,
|
|
||||||
is_published, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`),
|
|
||||||
|
|
||||||
region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`),
|
|
||||||
|
|
||||||
regionArea: db.prepare(`
|
|
||||||
INSERT INTO region_areas (region_id, area_code, share, edge, note)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`),
|
|
||||||
|
|
||||||
chapter: db.prepare(`
|
|
||||||
INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`),
|
|
||||||
|
|
||||||
section: db.prepare(`
|
|
||||||
INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`),
|
|
||||||
|
|
||||||
event: db.prepare(`
|
|
||||||
INSERT INTO events
|
|
||||||
(id, section_id, host_org_id, title, theme,
|
|
||||||
date_label, status,
|
|
||||||
locality, state_code, country, location_label, is_online,
|
|
||||||
org_logo, event_logo, color, gradient, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
||||||
|
|
||||||
block: db.prepare(`
|
|
||||||
INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`),
|
|
||||||
|
|
||||||
link: db.prepare(`
|
|
||||||
INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
||||||
};
|
|
||||||
|
|
||||||
const addParagraph = (kind, id, slot, order, text) => {
|
|
||||||
if (!text) return;
|
|
||||||
ins.block.run(kind, id, slot, order, "paragraph", text);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Clear ─────────────────────────────────────────────────────
|
|
||||||
Children before parents. Feedback is not in this list and is
|
|
||||||
never cleared.
|
|
||||||
───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const CLEAR = [
|
|
||||||
"people_list_members", "people_lists",
|
|
||||||
"person_awards", "awards",
|
|
||||||
"event_people", "affiliations", "teams",
|
|
||||||
"person_private", "people",
|
|
||||||
"content_block_items", "content_blocks", "links",
|
|
||||||
"events", "event_sections",
|
|
||||||
"chapters", "region_areas", "regions", "organizations",
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ── Import ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const counts = {};
|
|
||||||
const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n);
|
|
||||||
|
|
||||||
tx(db, () => {
|
|
||||||
for (const table of CLEAR) db.exec(`DELETE FROM ${table}`);
|
|
||||||
db.exec("DELETE FROM sqlite_sequence");
|
|
||||||
|
|
||||||
/* ── The root organization ───────────────────────────────── */
|
|
||||||
|
|
||||||
ins.org.run(
|
|
||||||
NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo,
|
|
||||||
null, null, null, "US", null, 0, 0,
|
|
||||||
);
|
|
||||||
bump("organizations");
|
|
||||||
|
|
||||||
/* ── Regions ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
GROUPS.forEach((group, index) => {
|
|
||||||
ins.org.run(
|
|
||||||
group.id, "region", group.name, null, null, group.color, null,
|
|
||||||
null, null, null, "US", null, 0, index,
|
|
||||||
);
|
|
||||||
ins.region.run(group.id, group.scope, group.note ?? null);
|
|
||||||
bump("organizations");
|
|
||||||
bump("regions");
|
|
||||||
|
|
||||||
// Whole areas. Split states are skipped here and handled below,
|
|
||||||
// which matters for Iowa — it appears in great-lakes.states AND
|
|
||||||
// in SPLITS, and inserting it twice would violate the key.
|
|
||||||
for (const area of group.states) {
|
|
||||||
if (SPLITS[area]) continue;
|
|
||||||
ins.regionArea.run(group.id, area, 1.0, null, null);
|
|
||||||
bump("region_areas");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shared areas, one row per region. The old SPLITS gave the
|
|
||||||
// sliver an explicit share and left the primary implicit; both
|
|
||||||
// are explicit now, so the renderer never subtracts.
|
|
||||||
for (const [area, split] of Object.entries(SPLITS)) {
|
|
||||||
ins.regionArea.run(
|
|
||||||
split.primary, area,
|
|
||||||
Number((1 - split.share).toFixed(4)),
|
|
||||||
opposite(split.edge),
|
|
||||||
split.primaryNote ?? null,
|
|
||||||
);
|
|
||||||
ins.regionArea.run(
|
|
||||||
split.secondary, area, split.share, split.edge, split.secondaryNote ?? null,
|
|
||||||
);
|
|
||||||
bump("region_areas", 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chapters ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
CHAPTERS.forEach((chapter, index) => {
|
|
||||||
const place = chapterLocation(chapter);
|
|
||||||
const region = groupOf(chapter);
|
|
||||||
|
|
||||||
if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`);
|
|
||||||
|
|
||||||
ins.org.run(
|
|
||||||
chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null,
|
|
||||||
chapter.where ?? null,
|
|
||||||
place.locality, place.state_code, place.country,
|
|
||||||
place.location_label, place.is_online,
|
|
||||||
index,
|
|
||||||
);
|
|
||||||
ins.chapter.run(
|
|
||||||
chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null,
|
|
||||||
);
|
|
||||||
bump("organizations");
|
|
||||||
bump("chapters");
|
|
||||||
|
|
||||||
addParagraph("organization", chapter.id, "body", 0, chapter.about);
|
|
||||||
|
|
||||||
let order = 0;
|
|
||||||
if (chapter.link) {
|
|
||||||
ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1);
|
|
||||||
bump("links");
|
|
||||||
}
|
|
||||||
if (chapter.contact) {
|
|
||||||
ins.link.run(
|
|
||||||
"organization", chapter.id, order++, "email", null,
|
|
||||||
chapter.contact, `mailto:${chapter.contact}`, 0,
|
|
||||||
);
|
|
||||||
bump("links");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapter.leads) {
|
|
||||||
warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Event sections ────────────────────────────────────────
|
|
||||||
Ids only. Titles, accents, colours, backgrounds and default
|
|
||||||
views stay in Retreats.jsx.
|
|
||||||
─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
eventsData.sections.forEach((section, index) => {
|
|
||||||
ins.section.run(section.id, section.title, index);
|
|
||||||
bump("event_sections");
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Events ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const regionIds = new Set(GROUPS.map((g) => g.id));
|
|
||||||
|
|
||||||
// National retreats belong to NGU. Regional ones name their region
|
|
||||||
// in the slug ("northwest-2026"). Partner placeholders have no
|
|
||||||
// organization yet.
|
|
||||||
function hostFor(event, sectionId) {
|
|
||||||
if (sectionId === "national") return NGU.id;
|
|
||||||
if (sectionId === "regional") {
|
|
||||||
const match = [...regionIds]
|
|
||||||
.filter((id) => event.id.startsWith(`${id}-`))
|
|
||||||
.sort((a, b) => b.length - a.length)[0];
|
|
||||||
if (match) return match;
|
|
||||||
warn(`Event "${event.id}" is regional but names no region — host left null.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
warn(`Event "${event.id}" has no partner organization — host left null.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const section of eventsData.sections) {
|
|
||||||
section.events.forEach((event, index) => {
|
|
||||||
const place = eventLocation(event.location);
|
|
||||||
|
|
||||||
ins.event.run(
|
|
||||||
event.id, section.id, hostFor(event, section.id),
|
|
||||||
event.title, event.theme ?? null,
|
|
||||||
event.date ?? null, event.status ?? null,
|
|
||||||
place.locality, place.state_code, place.country,
|
|
||||||
place.location_label, place.is_online,
|
|
||||||
event.org_logo ?? null, event.image ?? null,
|
|
||||||
event.color ?? null, event.gradient ?? null,
|
|
||||||
index,
|
|
||||||
);
|
|
||||||
bump("events");
|
|
||||||
|
|
||||||
// desc_a and desc_b become the card slot, in order. The body
|
|
||||||
// slot is left empty for the full page you'll write later.
|
|
||||||
addParagraph("event", event.id, "card", 0, event.desc_a);
|
|
||||||
addParagraph("event", event.id, "card", 1, event.desc_b);
|
|
||||||
|
|
||||||
let order = 0;
|
|
||||||
(event.links ?? []).forEach((link, i) => {
|
|
||||||
if (!/^https?:\/\//.test(link.link)) {
|
|
||||||
warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`);
|
|
||||||
}
|
|
||||||
ins.link.run(
|
|
||||||
"event", event.id, order++, "action", null,
|
|
||||||
link.label, link.link, i === 0 ? 1 : 0,
|
|
||||||
);
|
|
||||||
bump("links");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event.instagram) {
|
|
||||||
ins.link.run(
|
|
||||||
"event", event.id, order++, "social", "instagram",
|
|
||||||
event.instagram, instagramUrl(event.instagram), 0,
|
|
||||||
);
|
|
||||||
bump("links");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
/* ── Report ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
console.log(`\nSeeded ${DB_PATH}\n`);
|
|
||||||
for (const [table, n] of Object.entries(counts).sort()) {
|
|
||||||
console.log(` ${String(n).padStart(4)} ${table}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`);
|
|
||||||
for (const message of warnings) console.log(` · ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
|
|
@ -144,4 +144,26 @@ export function splitLinks(links = []) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Event series ──────────────────────────────────────────────
|
||||||
|
The repeating schedule, or null for a one-off. Weekdays
|
||||||
|
collapse from seven flags to a list of the ticked ones, Sunday
|
||||||
|
first; an empty list means "starts_on's weekday", which the
|
||||||
|
client resolves since it already holds starts_on. Occurrences
|
||||||
|
are not sent — they are derived, and the client derives them
|
||||||
|
against its own today. Shared by /events and /front-page.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
|
||||||
|
export function shapeSeries(row) {
|
||||||
|
if (!asBool(row.is_series)) return null;
|
||||||
|
return {
|
||||||
|
frequency: row.series_frequency,
|
||||||
|
interval: row.series_interval,
|
||||||
|
weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])),
|
||||||
|
start_time: row.series_start_time,
|
||||||
|
end_time: row.series_end_time,
|
||||||
|
count: row.series_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export { asBool };
|
export { asBool };
|
||||||
|
|
|
||||||
53
src/App.tsx
53
src/App.tsx
|
|
@ -1,17 +1,42 @@
|
||||||
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";
|
||||||
|
import RequireRole from "./pages/admin/RequireRole.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";
|
||||||
|
import History from "./pages/History.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";
|
||||||
|
|
||||||
|
/*Entity Pages*/
|
||||||
|
import EventDetail from './pages/EventDetail.tsx'
|
||||||
|
import OrganizationDetail from './pages/OrganizationDetail.tsx'
|
||||||
|
import TeamDetail from './pages/TeamDetail.tsx'
|
||||||
|
import AwardDetail from './pages/AwardDetail.tsx'
|
||||||
|
import PersonDetail from './pages/PersonDetail.tsx'
|
||||||
|
|
||||||
|
/*Admin Pages*/
|
||||||
|
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
||||||
|
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
||||||
|
import AdminPanel from "./pages/admin/AdminPanel.tsx";
|
||||||
|
import AdminHome from "./pages/admin/AdminHome.tsx";
|
||||||
|
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
||||||
|
import EntityList from "./pages/admin/EntityList.tsx";
|
||||||
|
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
||||||
|
|
||||||
|
/*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() {
|
||||||
|
|
@ -22,15 +47,39 @@ export default function App() {
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="retreats" element={<Retreats />} />
|
<Route path="retreats" element={<Retreats />} />
|
||||||
|
<Route path="/events/:id" element={<EventDetail />} />
|
||||||
<Route path="community" element={<Community />} />
|
<Route path="community" element={<Community />} />
|
||||||
|
<Route path="/regions/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/chapters/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/partners/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/organizations/:id" element={<OrganizationDetail />} />
|
||||||
|
<Route path="/teams/:id" element={<TeamDetail />} />
|
||||||
|
<Route path="/awards/:id" element={<AwardDetail />} />
|
||||||
|
<Route path="/people/:id" element={<PersonDetail />} />
|
||||||
<Route path="leadership" element={<Leadership />} />
|
<Route path="leadership" element={<Leadership />} />
|
||||||
<Route path="resources" element={<Resources />} />
|
<Route path="resources" element={<Resources />} />
|
||||||
|
<Route path="history" element={<History />} />
|
||||||
<Route path="feedback" element={<Feedback />} />
|
<Route path="feedback" element={<Feedback />} />
|
||||||
<Route path="give" element={<Giving />} />
|
<Route path="give" element={<Giving />} />
|
||||||
<Route path="privacy" element={<Privacy />} />
|
<Route path="privacy" element={<Privacy />} />
|
||||||
<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/home" replace />} />
|
||||||
|
<Route path="home" element={<AdminHome />} />
|
||||||
|
<Route element={<RequireRole role="superadmin" />}>
|
||||||
|
<Route path="panel" element={<AdminPanel />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="feedback" element={<AdminFeedback />} />
|
||||||
|
<Route path=":entity" element={<EntityList />} />
|
||||||
|
<Route path=":entity/:id" element={<EntityEdit />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
292
src/App.tsx.save
292
src/App.tsx.save
|
|
@ -1,292 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import nguLogo from "@/NGU_Logo.svg";
|
|
||||||
import fallLogo from "@/Fall Logo.svg";
|
|
||||||
import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg";
|
|
||||||
|
|
||||||
{/* SVGs */}
|
|
||||||
const DoveSVG = ({ className = "" }: { className?: string }) => (
|
|
||||||
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs id="defs1" />
|
|
||||||
<g id="layer1" transform="translate(-70.490069,-117.83965)">
|
|
||||||
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
|
|
||||||
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const InstagramIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
|
|
||||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FacebookIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
|
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DiscordIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
|
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
{/* Link Tables */}
|
|
||||||
const Social_Links = [
|
|
||||||
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", icon: <InstagramIcon />},
|
|
||||||
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", icon: <FacebookIcon />},
|
|
||||||
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", icon: <DiscordIcon />},
|
|
||||||
]
|
|
||||||
|
|
||||||
const Nav_Links = [
|
|
||||||
{ label: "About", href: "#about"},
|
|
||||||
{ label: "Events", href: "#events"},
|
|
||||||
{ label: "Connect", href: "#connect"},
|
|
||||||
]
|
|
||||||
|
|
||||||
const Footer_Links = [
|
|
||||||
{ label: "Privacy Policy", href: "#"},
|
|
||||||
{ label: "Terms of Service", href: "#"},
|
|
||||||
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
|
|
||||||
]
|
|
||||||
|
|
||||||
{/* Functions */}
|
|
||||||
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.split("").map((char, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="float-anim"
|
|
||||||
style={{ animationDelay: `${-i * step}s` }}
|
|
||||||
>
|
|
||||||
{char === " " ? "\u00A0" : char}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState("main");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen overflow-x-hidden">
|
|
||||||
|
|
||||||
{/* ── NAV ─────────────────────────────────────────────── */}
|
|
||||||
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 backdrop-blur-md" style={{ background: "rgba(0, 69, 82,0.92)", borderBottom: "1px solid rgba(45,200,224,0.15)" }}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<a href="/" aria-label="Next Generation of Unity home" className="inline-block">
|
|
||||||
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop links */}
|
|
||||||
<div className="hidden md:flex items-center gap-8">
|
|
||||||
{Nav_Links.map((link) => (
|
|
||||||
<a key={link.label} href={link.href} className="text-white/90 hover:text-[#aac992] text-sm font-600 transition-colors duration-200">
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
<a href="https://ngu.churchcenter.com/giving" className="px-5 py-2 rounded-full text-sm font-700 text-white transition-all duration-200 hover:scale-105 font-bold leading-relaxed" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}>
|
|
||||||
Give
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu btn */}
|
|
||||||
<button className="md:hidden text-white p-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
|
||||||
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all"/>
|
|
||||||
<div className="w-6 h-0.5 bg-white mb-1.5"/>
|
|
||||||
<div className="w-6 h-0.5 bg-white"/>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center" style={{ background: "rgba(7,61,74,0.97)" }}>
|
|
||||||
<button className="absolute top-5 right-6 text-white text-3xl font-300" onClick={() => setMobileMenuOpen(false)}>×</button>
|
|
||||||
{Nav_Links.map((link) => (
|
|
||||||
<a key={link.label} href={link.href} className="text-white text-2xl font-700 py-3 hover:text-[#10d48a] transition-colors" onClick={() => setMobileMenuOpen(false)}>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
<a href="https://ngu.churchcenter.com/giving" className="mt-6 px-8 py-3 rounded-full text-white text-lg font-700 font-bold" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)" }} onClick={() => setMobileMenuOpen(false)}>
|
|
||||||
Give
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
|
|
||||||
<section id="about" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
|
|
||||||
{/* Floating doves */}
|
|
||||||
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
|
|
||||||
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
|
|
||||||
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
|
||||||
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Next Generation<br />
|
|
||||||
<span className="grad-hero-text">
|
|
||||||
<WaveText text="of Unity" step={0.1} />
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
|
|
||||||
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
|
|
||||||
</p>
|
|
||||||
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
|
|
||||||
We are the future of the Unity Movement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 justify-center">
|
|
||||||
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Follow Us on Instagram
|
|
||||||
</a>
|
|
||||||
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Attend a Retreat
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── ABOUT/INFO ─────────────────────────────────────── */}
|
|
||||||
<section className="py-24 px-6" style={{ background: "#f0fcfd" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
|
|
||||||
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
|
||||||
{[
|
|
||||||
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
|
|
||||||
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
|
|
||||||
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
|
|
||||||
].map((card) => (
|
|
||||||
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
|
|
||||||
<div className="text-4xl mb-4">{card.icon}</div>
|
|
||||||
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
|
|
||||||
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* ── EVENTS ─────────────────────────────────────────────── */}
|
|
||||||
<section id="events" className="py-24 px-6" style={{ background: "#eef9fb" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
|
|
||||||
Upcoming Events
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured event card */}
|
|
||||||
<div className="rounded-3xl text-black overflow-hidden shadow-2xl max-w-3xl mx-auto mb-10" style={{ border: "1px solid #b89421", background: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))" }}>
|
|
||||||
<div className="p-10">
|
|
||||||
<div className="grid grid-cols-2 mb-4">
|
|
||||||
<div className="">
|
|
||||||
<img src={nguLogo_WhiteBG} alt="Next Generation of Unity" className="h-15 w-auto mb-6" />
|
|
||||||
<h3 className="text-4xl md:text-4xl font-900">Fall Retreat 2026</h3>
|
|
||||||
<p className="text-2xl font-300 font-bold">"Consciousness Creates"</p>
|
|
||||||
<p className="text-2xl">November 12-15th, 2026</p>
|
|
||||||
<p className="text-2xl">Unity Village, MO</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<img src={fallLogo} alt="Next Generation of Unity" className="h-60" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mb-2 leading-relaxed">Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus your highest self.</p>
|
|
||||||
<p className="mb-8 leading-relaxed">Registration starting at $150, and $75 loding cost.</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: "Register Now!", link:"https://ngu.churchcenter.com/registrations/events/3761999"},
|
|
||||||
{ label: "Workshop Signup", link:"https://ngu.churchcenter.com/people/forms/1285943"},
|
|
||||||
{ label: "Scholarship Application", link:"https://ngu.churchcenter.com/people/forms/1261992"},
|
|
||||||
].map(item => (
|
|
||||||
<a key={item.label} href={item.link} className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center" style={{ border: "1px solid #b89421" }}>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-[#138ba0] font-600 text-sm">· More events coming soon, stay connected for announcements ·</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* ── CONNECT / SOCIALS ─────────────────────────────────── */}
|
|
||||||
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Ready to Connect?
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
|
|
||||||
{[
|
|
||||||
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
|
|
||||||
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
|
|
||||||
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
|
|
||||||
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
|
|
||||||
].map(item => (
|
|
||||||
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
|
|
||||||
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
{/* Arrow */}
|
|
||||||
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-10 text-center">
|
|
||||||
<a href="https://ngu.churchcenter.com/calendar?view=gallery" className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}>
|
|
||||||
📅 View NGU Calendar
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── FOOTER - Using #042f3a for BG ─────────────────────────────────────── */}
|
|
||||||
<footer className="py-16 px-6" style={{ background: "linear-gradient(135deg, #042f3a 0%, #073d4a 100%)" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{Social_Links.map((link) => (
|
|
||||||
<a key={link.label} href={link.href} className="w-10 h-10 rounded-full flex items-center justify-center text-white/70 hover:text-white transition-colors" style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}>
|
|
||||||
{link.icon}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
||||||
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{Footer_Links.map((link) => (
|
|
||||||
<a key={link.label} href={link.href} className="text-white/40 hover:text-[#10d48a] text-sm transition-colors">{link.label}</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { SITE_BANNER } from "../data/bannerConfig.js";
|
import { SITE_BANNER } from "../data/bannerConfig.ts";
|
||||||
|
|
||||||
export default function Banner() {
|
export default function Banner() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
|
||||||
189
src/components/ContentBlocks.tsx
Normal file
189
src/components/ContentBlocks.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
CONTENT BLOCKS
|
||||||
|
|
||||||
|
Renders the `body` slot of content_blocks. Events, organizations
|
||||||
|
and teams all own blocks and all render them the same way, so
|
||||||
|
this is written once and tinted by the caller's accent.
|
||||||
|
|
||||||
|
⚠ Assumes shape.js's loadBlocks returns rows carrying `type`,
|
||||||
|
`text`, `media`, `href` and an `items` array. If the field names
|
||||||
|
differ, this is the only file to fix — nothing else reads a
|
||||||
|
block.
|
||||||
|
|
||||||
|
An unknown `type` renders its text as a paragraph rather than
|
||||||
|
disappearing. A block somebody typed into the admin should be
|
||||||
|
visible even if the renderer hasn't caught up with it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { blockMedia } from '../lib/media.ts'
|
||||||
|
import type { ContentBlock } from '../lib/useContent.ts'
|
||||||
|
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
/** External if it has a scheme or starts with //; otherwise it's
|
||||||
|
* one of our own routes and should go through the router. */
|
||||||
|
const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)
|
||||||
|
|
||||||
|
function Anchor({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}) {
|
||||||
|
if (isExternal(href)) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer" className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link to={href} className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentBlocks({
|
||||||
|
blocks,
|
||||||
|
accent = '#138ba0',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
blocks?: ContentBlock[] | null
|
||||||
|
accent?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (!blocks?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${className}`}>
|
||||||
|
{blocks.map((block, index) => (
|
||||||
|
<Block key={block.id ?? index} block={block} accent={accent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({ block, accent }: { block: ContentBlock; accent: string }) {
|
||||||
|
const text = block.text ?? ''
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'heading':
|
||||||
|
return (
|
||||||
|
<h3 className="text-2xl font-bold" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'subheading':
|
||||||
|
return (
|
||||||
|
<h4 className="text-lg font-semibold" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</h4>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(block.items ?? []).map((item, index) => (
|
||||||
|
<li key={index} className="flex gap-3">
|
||||||
|
<span aria-hidden="true" style={{ color: accent }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
{item.url ? (
|
||||||
|
<Anchor href={item.url} className="underline" style={{ color: accent }}>
|
||||||
|
{item.text}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
item.text
|
||||||
|
)}
|
||||||
|
{item.detail && (
|
||||||
|
<span className="opacity-70"> — {item.detail}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{(block.items ?? [])
|
||||||
|
.filter((item) => item.url)
|
||||||
|
.map((item, index) => (
|
||||||
|
<Anchor
|
||||||
|
key={index}
|
||||||
|
href={item.url as string}
|
||||||
|
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'quote':
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
className="border-l-2 pl-5 text-lg italic"
|
||||||
|
style={{ borderColor: accent, color: BODY }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</blockquote>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'image': {
|
||||||
|
const src = blockMedia(block.media)
|
||||||
|
if (!src) return null
|
||||||
|
const img = (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
// A caption is a caption, not alt text — but an image with
|
||||||
|
// neither is decorative, and alt="" is the correct answer
|
||||||
|
// for that rather than a filename read aloud.
|
||||||
|
alt={text}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full rounded-lg"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<figure>
|
||||||
|
{block.href ? <Anchor href={block.href}>{img}</Anchor> : img}
|
||||||
|
{text && (
|
||||||
|
<figcaption className="mt-2 text-sm opacity-70" style={{ color: BODY }}>
|
||||||
|
{text}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'divider':
|
||||||
|
return <hr className="border-0 h-px" style={{ background: accent, opacity: 0.3 }} />
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
default:
|
||||||
|
if (!text) return null
|
||||||
|
return (
|
||||||
|
<p className="leading-relaxed" style={{ color: BODY }}>
|
||||||
|
{block.href ? (
|
||||||
|
<Anchor href={block.href} className="underline" style={{ color: accent }}>
|
||||||
|
{text}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/components/DoveMark.tsx
Normal file
30
src/components/DoveMark.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
DOVE MARK
|
||||||
|
|
||||||
|
NGU's dove, lifted from the original home page. The path and its
|
||||||
|
two transforms are the drawing as exported, untouched; only the
|
||||||
|
wrapper changed — sized by the caller, coloured by currentColor,
|
||||||
|
and hidden from screen readers since it's decoration wherever
|
||||||
|
it appears.
|
||||||
|
|
||||||
|
Renders as an <svg> element, so it nests inside another SVG as
|
||||||
|
well as in HTML: pass x, y, width and height to place it in a
|
||||||
|
parent viewBox.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
const PATH =
|
||||||
|
'm 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z'
|
||||||
|
|
||||||
|
export default function DoveMark(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72.867699 48.568241" aria-hidden="true" focusable="false" {...props}>
|
||||||
|
<g transform="translate(-70.490069,-117.83965)">
|
||||||
|
<g transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)">
|
||||||
|
<path d={PATH} fill="currentColor" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts";
|
||||||
import nguLogo from "../assets/NGU_Logo.svg";
|
import nguLogo from "../assets/NGU_Logo.svg";
|
||||||
|
|
||||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
|
|
@ -39,7 +39,13 @@ const Social_Links = [
|
||||||
// Only the label differs down here.
|
// Only the label differs down here.
|
||||||
const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy");
|
const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy");
|
||||||
|
|
||||||
const Get_In_Touch = [
|
/* An internal route, or an off-site address opened in a new tab. */
|
||||||
|
type TouchLink = { label: string } & (
|
||||||
|
| { external?: false; to: string }
|
||||||
|
| { external: true; href: string }
|
||||||
|
);
|
||||||
|
|
||||||
|
const Get_In_Touch: TouchLink[] = [
|
||||||
{ label: "Feedback", to: "/feedback"},
|
{ label: "Feedback", to: "/feedback"},
|
||||||
{ label: "Contact Us", to: "/leadership#contact" },
|
{ label: "Contact Us", to: "/leadership#contact" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts";
|
||||||
import Banner from "./Banner.jsx";
|
import Banner from "./Banner.jsx";
|
||||||
import nguLogo from "../assets/NGU_Logo.svg";
|
import nguLogo from "../assets/NGU_Logo.svg";
|
||||||
import Footer from "./Footer.tsx";
|
import Footer from "./Footer.tsx";
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,27 @@
|
||||||
/>
|
/>
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const TEAL = "#138ba0";
|
const TEAL = "#138ba0";
|
||||||
|
|
||||||
export function Section({ section }) {
|
export type ShellSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blurb?: string;
|
||||||
|
accent: string;
|
||||||
|
background: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageShellProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
intro?: ReactNode;
|
||||||
|
sections: ShellSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Section({ section }: { section: ShellSection }) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -70,7 +88,7 @@ export function Section({ section }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageShell({ title, intro, sections }) {
|
export default function PageShell({ title, intro, sections }: PageShellProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
|
|
|
||||||
91
src/components/PageState.tsx
Normal file
91
src/components/PageState.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PAGE STATE
|
||||||
|
|
||||||
|
The three ways a detail page can fail to be a detail page. All
|
||||||
|
four of them need the same thing, and it should be the same thing
|
||||||
|
— a visitor who hits a dead retreat link and a dead chapter link
|
||||||
|
shouldn't get two different pages.
|
||||||
|
|
||||||
|
Rendered inside PageShell so the chrome doesn't flicker in and
|
||||||
|
out between loading and loaded.
|
||||||
|
|
||||||
|
A 404 gets no retry button: the slug doesn't exist and trying
|
||||||
|
again won't change that. Anything else does, because a dropped
|
||||||
|
connection is the usual cause and one tap fixes it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PageShell from './PageShell.tsx'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function PageState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
notFound,
|
||||||
|
onRetry,
|
||||||
|
noun,
|
||||||
|
backTo,
|
||||||
|
backLabel,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
notFound: boolean
|
||||||
|
onRetry: () => void
|
||||||
|
/** Lowercase, as it appears mid-sentence: "retreat", "chapter". */
|
||||||
|
noun: string
|
||||||
|
backTo: string
|
||||||
|
backLabel: string
|
||||||
|
}) {
|
||||||
|
let title: string
|
||||||
|
let content: React.ReactNode
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
title = 'Not found'
|
||||||
|
content = (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<p style={{ color: BODY }}>
|
||||||
|
There’s no {noun} at this address. It may have been renamed, or taken
|
||||||
|
down.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={backTo}
|
||||||
|
className="mt-4 inline-block rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (error) {
|
||||||
|
title = 'Something went wrong'
|
||||||
|
content = (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<p style={{ color: '#b3261e' }}>Couldn’t load this {noun}. {error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-3 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
title = 'Loading…'
|
||||||
|
content = (
|
||||||
|
<p className="max-w-6xl mx-auto px-6" style={{ color: BODY }} role="status">
|
||||||
|
Loading this {noun}…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={title}
|
||||||
|
sections={[{ id: 'status', title: '', accent: TEAL, background: '#ffffff', content }]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -123,10 +123,16 @@
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl__tile--button {
|
.pl__tile--button,
|
||||||
|
.pl__tile--link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pl__tile--link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pl__frame {
|
.pl__frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -150,16 +156,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl__tile--button:hover .pl__frame,
|
.pl__tile--button:hover .pl__frame,
|
||||||
.pl__tile--button:focus-visible .pl__frame {
|
.pl__tile--button:focus-visible .pl__frame,
|
||||||
|
.pl__tile--link:hover .pl__frame,
|
||||||
|
.pl__tile--link:focus-visible .pl__frame {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6);
|
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl__tile--button:focus-visible {
|
.pl__tile--button:focus-visible,
|
||||||
|
.pl__tile--link:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl__tile--button:focus-visible .pl__frame {
|
.pl__tile--button:focus-visible .pl__frame,
|
||||||
|
.pl__tile--link:focus-visible .pl__frame {
|
||||||
outline: 2px solid var(--pl-accent);
|
outline: 2px solid var(--pl-accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +321,20 @@
|
||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pl__profile {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--pl-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl__profile:hover,
|
||||||
|
.pl__profile:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.pl__empty {
|
.pl__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,380 @@
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get } from "../lib/api.ts";
|
||||||
|
import { isBadId, personHref } from "../lib/hrefs.ts";
|
||||||
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
|
* Profiles
|
||||||
* {
|
* A person with a string id is taken to be a people row and links
|
||||||
* id, name,
|
* to /people/:id. A tile with nothing to expand is that link; an
|
||||||
* title?, // "Regional Director"
|
* expandable one stays the button that opens its panel — a link
|
||||||
* photo?, // "/people/jane-doe.jpg" — falls back to initials
|
* can't sit inside a button — and the panel carries the link
|
||||||
* pronouns?, // "she/her"
|
* instead. A hand-written entry with no id links nowhere.
|
||||||
* 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 any)[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 +382,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 +412,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 +425,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 +453,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 +468,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 +483,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 +513,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 +527,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,11 +559,17 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!expandable) {
|
if (!expandable) {
|
||||||
return <div className="pl__tile">{content}</div>;
|
const href = profileHref(person);
|
||||||
|
return href ? (
|
||||||
|
<Link to={href} className="pl__tile pl__tile--link">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="pl__tile">{content}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -212,12 +581,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 +613,22 @@ 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;
|
||||||
|
const profile = profileHref(person);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -255,20 +636,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 +656,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,14 +670,33 @@ 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">
|
||||||
{paragraph}
|
{paragraph}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{profile && (
|
||||||
|
<Link to={profile} className="pl__profile">
|
||||||
|
View full profile →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -323,13 +716,19 @@ function Chevron() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helpers ------------------------------------------------------------- */
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function keyFor(group, person, index) {
|
/* A string id is a people slug; a numeric or missing one is a
|
||||||
|
hand-written entry with no page behind it. */
|
||||||
|
function profileHref(person: Person): string | null {
|
||||||
|
return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +739,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 +778,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
423
src/components/admin/fields.tsx
Normal file
423
src/components/admin/fields.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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) {
|
||||||
|
// An entity with no slug has no heading path either, and a missing
|
||||||
|
// path should read as "no value" rather than throwing on .split.
|
||||||
|
if (!path) return undefined;
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: any) {
|
||||||
|
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||||
|
const widget = field.widget ?? "text";
|
||||||
|
const locked = Boolean(field.readOnly);
|
||||||
|
|
||||||
|
let list: any = 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" || widget === "date" || widget === "time" ? widget : "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<any>(null);
|
||||||
|
const [overIndex, setOverIndex] = useState<any>(null);
|
||||||
|
const rowRefs = useRef<any[]>([]);
|
||||||
|
|
||||||
|
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 = false, disabled = false, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
|
|
||||||
Two things live elsewhere:
|
Two things live elsewhere:
|
||||||
|
|
||||||
the tile grid src/data/mapGrid.js. Where a state sits never
|
the tile grid src/data/mapGrid.ts. Where a state sits never
|
||||||
changes, so it isn't worth a round trip.
|
changes, so it isn't worth a round trip.
|
||||||
|
|
||||||
the fetch src/data/organizations.js. One endpoint for
|
the fetch src/data/organizations.ts. One endpoint for
|
||||||
every kind, so a section listing regions and
|
every kind, so a section listing regions and
|
||||||
a section listing partners read alike.
|
a section listing partners read alike.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -25,12 +25,12 @@ import {
|
||||||
areasSentence,
|
areasSentence,
|
||||||
initialsFor,
|
initialsFor,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "./organizations.js";
|
} from "./organizations.ts";
|
||||||
import {
|
import {
|
||||||
areaForChapter,
|
areaForChapter,
|
||||||
buildAreaSlices,
|
buildAreaSlices,
|
||||||
countChaptersByArea,
|
countChaptersByArea,
|
||||||
} from "./mapGrid.js";
|
} from "./mapGrid.ts";
|
||||||
|
|
||||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
|
||||||
EVENT DATA
|
|
||||||
|
|
||||||
One request, filtered per section. The Retreats page has three
|
|
||||||
bands of events, and all three call this hook — the cache in
|
|
||||||
api.js keys on the path, so they share a single fetch and each
|
|
||||||
narrows the result to what it shows.
|
|
||||||
|
|
||||||
useEvents({ section: "national" }) one band
|
|
||||||
useEvents({ host: "northwest" }) a region's own events
|
|
||||||
useEvents({ status: "upcoming" }) a home page strip
|
|
||||||
useEvents() everything
|
|
||||||
|
|
||||||
Filtering here rather than in the query keeps the endpoint to
|
|
||||||
one cached response. At a few dozen events that's the right
|
|
||||||
trade; if the list ever runs to hundreds, move the filters into
|
|
||||||
the URL and let each become its own cache entry.
|
|
||||||
═══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useResource } from "../lib/useResource.js";
|
|
||||||
|
|
||||||
const EMPTY = { events: [] };
|
|
||||||
|
|
||||||
export function useEvents({ section, host, status } = {}) {
|
|
||||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
|
||||||
|
|
||||||
const all = data?.events;
|
|
||||||
|
|
||||||
const events = useMemo(() => {
|
|
||||||
let list = all ?? [];
|
|
||||||
if (section) list = list.filter(e => e.section_id === section);
|
|
||||||
if (host) list = list.filter(e => e.host?.id === host);
|
|
||||||
if (status) list = list.filter(e => e.status === status);
|
|
||||||
return list;
|
|
||||||
}, [all, section, host, status]);
|
|
||||||
|
|
||||||
return { events, loading, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
|
||||||
explicit value when there is one, otherwise derived from ends_on
|
|
||||||
— so nothing here needs to know which of the two it got. */
|
|
||||||
export function splitByStatus(events = []) {
|
|
||||||
const upcoming = [];
|
|
||||||
const past = [];
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
(event.status === "past" ? past : upcoming).push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { upcoming, past };
|
|
||||||
}
|
|
||||||
92
src/data/eventData.ts
Normal file
92
src/data/eventData.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT DATA
|
||||||
|
|
||||||
|
One request, filtered per band. The Retreats page has three
|
||||||
|
bands of events, and all three call this hook — the cache in
|
||||||
|
api.ts keys on the path, so they share a single fetch and each
|
||||||
|
narrows the result to what it shows.
|
||||||
|
|
||||||
|
useEvents({ scope: "national" }) one band
|
||||||
|
useEvents({ host: "northwest" }) one host's events
|
||||||
|
useEvents({ status: "upcoming" }) a home page strip
|
||||||
|
useEvents({ type: "workshop" }) one kind, wherever it is
|
||||||
|
useEvents({ type: ["class", "workshop"] })
|
||||||
|
useEvents() everything
|
||||||
|
|
||||||
|
`scope` and `type` are different questions and stack rather
|
||||||
|
than overlap: the scope is whose gathering an event is, the type
|
||||||
|
is what kind of gathering it is. A regional class matches both
|
||||||
|
{ scope: "regional" } and { type: "class" }.
|
||||||
|
|
||||||
|
The event_scopes rows come back alongside, for anything that
|
||||||
|
offers a scope filter.
|
||||||
|
|
||||||
|
No fallback. An empty list on a failed request would read as
|
||||||
|
"nothing scheduled" when the truth is "the server is down", so
|
||||||
|
the error comes back and each caller says so.
|
||||||
|
|
||||||
|
Filtering here rather than in the query keeps the endpoint to
|
||||||
|
one cached response. At a few dozen events that's the right
|
||||||
|
trade; if the list ever runs to hundreds, move the filters into
|
||||||
|
the URL and let each become its own cache entry.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useResource } from "../lib/useResource.ts";
|
||||||
|
|
||||||
|
/* One shared empty list, so a memo keyed on `scopes` doesn't
|
||||||
|
restart on every render before the data arrives. */
|
||||||
|
const NO_SCOPES: any[] = [];
|
||||||
|
|
||||||
|
export function useEvents({ scope, host, status, type }: any = {}) {
|
||||||
|
const { data, error, loading } = useResource("/events");
|
||||||
|
|
||||||
|
const all = data?.events;
|
||||||
|
const scopes = data?.scopes ?? NO_SCOPES;
|
||||||
|
|
||||||
|
/* An array prop is a new identity on every render, which would
|
||||||
|
restart the memo each time. Joining it gives the dependency
|
||||||
|
list something stable to compare. */
|
||||||
|
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
|
||||||
|
|
||||||
|
const events = useMemo(() => {
|
||||||
|
let list = all ?? [];
|
||||||
|
if (scope) list = list.filter(e => e.scope_id === scope);
|
||||||
|
// `host` is an organization or a person slug, and an event can
|
||||||
|
// have several of either — co-hosting puts one event on both
|
||||||
|
// hosts' lists, which is the point.
|
||||||
|
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
|
||||||
|
if (status) list = list.filter(e => e.status === status);
|
||||||
|
if (typeKey) {
|
||||||
|
const wanted = new Set(typeKey.split(","));
|
||||||
|
list = list.filter(e => wanted.has(e.event_type));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [all, scope, host, status, typeKey]);
|
||||||
|
|
||||||
|
return { events, scopes, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||||
|
explicit value when there is one, otherwise derived from ends_on
|
||||||
|
— so nothing here needs to know which of the two it got. */
|
||||||
|
export function splitByStatus(events: any[] = []) {
|
||||||
|
const upcoming: any[] = [];
|
||||||
|
const past: any[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
(event.status === "past" ? past : upcoming).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upcoming, past };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Which of the declared types a list actually contains, in
|
||||||
|
EVENT_TYPES order rather than whatever order the rows arrived
|
||||||
|
in. A section with one type has nothing to filter, which is what
|
||||||
|
lets the chip bar hide itself. */
|
||||||
|
export function typesPresent(events: any[] = [], declared: any[] = []) {
|
||||||
|
const seen = new Set(events.map(e => e.event_type));
|
||||||
|
return declared.filter(entry => seen.has(entry.id));
|
||||||
|
}
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
|
||||||
EVENT DATA
|
|
||||||
Plain JS instead of JSON so it imports cleanly in any environment
|
|
||||||
(Figma included) — same shape, but comments and trailing commas
|
|
||||||
are allowed.
|
|
||||||
|
|
||||||
Per event:
|
|
||||||
org_logo hosting org's logo: a filename WITH extension in
|
|
||||||
public/event-logos/, e.g. "ngu-logo-white-bg.png".
|
|
||||||
null = fall back to DEFAULT_ORG_LOGO.
|
|
||||||
image event logo / flyer, same filename rule. A name with
|
|
||||||
no matching file just renders nothing.
|
|
||||||
instagram handle for the follow button, e.g. "@nextgenunity".
|
|
||||||
null = no Instagram button on this card.
|
|
||||||
color card outline / button color. null = the section's
|
|
||||||
defaultColor.
|
|
||||||
gradient card background. null = no gradient.
|
|
||||||
desc_a first paragraph
|
|
||||||
desc_b second paragraph (pricing, lodging, a note — anything)
|
|
||||||
status "upcoming" or "past" — grid view splits on this.
|
|
||||||
links [] when there's nothing to click yet.
|
|
||||||
|
|
||||||
Per section:
|
|
||||||
accent heading, banner, arrows, dots, fallback messages
|
|
||||||
defaultColor card color for events that don't set their own
|
|
||||||
defaultView "carousel" or "grid"
|
|
||||||
═══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
const eventsData = {
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "national",
|
|
||||||
title: "National Retreats",
|
|
||||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
|
||||||
accent: "#138ba0",
|
|
||||||
defaultColor: "#138ba0",
|
|
||||||
defaultView: "carousel",
|
|
||||||
background: "#eef9fb",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: "spring-2025",
|
|
||||||
title: "Spring Retreat 2026",
|
|
||||||
theme: "Altering Intertia",
|
|
||||||
date: "March/April 2026",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
org_logo: "ngu-logo-white-bg.svg",
|
|
||||||
image: "fall-retreat-logo.svg",
|
|
||||||
instagram: "@nextgenerationunity",
|
|
||||||
color: "#f1c2fe",
|
|
||||||
gradient: "linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a: "A weekend of connection, workshops, and community for young adults across the Unity movement.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "past",
|
|
||||||
links: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fall-retreat-2026",
|
|
||||||
title: "Fall Retreat 2026",
|
|
||||||
theme: "Consciousness Creates",
|
|
||||||
date: "November 12-15th, 2026",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
org_logo: "ngu-logo-white-bg.svg",
|
|
||||||
image: "fall-retreat-logo.svg",
|
|
||||||
instagram: "@nextgenerationunity",
|
|
||||||
color: "#b89421",
|
|
||||||
gradient: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a: "Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
|
|
||||||
desc_b: "Registration starting at $150, and $75 lodging cost.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Register Now!",
|
|
||||||
link: "https://ngu.churchcenter.com/registrations/events/3761999"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Scholarship Application",
|
|
||||||
link: "https://ngu.churchcenter.com/people/forms/1261992"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Volunteer",
|
|
||||||
link: "https://ngu.churchcenter.com/people/forms/1176908"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-recharge-2027",
|
|
||||||
title: "Spring Recharge 2027",
|
|
||||||
theme: "TBD",
|
|
||||||
date: "March 6th, 2027",
|
|
||||||
location: "Online",
|
|
||||||
org_logo: "ngu-logo-white-bg.svg",
|
|
||||||
image: null,
|
|
||||||
instagram: "@nextgenerationunity",
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "One-day online event to reconnect in the spring.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-service-2027",
|
|
||||||
title: "Service Week 2027",
|
|
||||||
theme: "Leadership & Service",
|
|
||||||
date: "April 4-9th, 2027",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
org_logo: "ngu-logo-white-bg.svg",
|
|
||||||
image: null,
|
|
||||||
instagram: "@nextgenerationunity",
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Join us at beautiful Unity Village for a week of leadership development and service projects.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "regional",
|
|
||||||
title: "Regional Retreats",
|
|
||||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
|
||||||
accent: "#aac992",
|
|
||||||
defaultColor: "#aac992",
|
|
||||||
defaultView: "grid",
|
|
||||||
background: "#ffffff",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: "northwest-2026",
|
|
||||||
title: "Northwest Regional 2026",
|
|
||||||
theme: "Theme name",
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: "@nw.ngu",
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the regional retreat goes here.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "past",
|
|
||||||
links: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "northwest-2027",
|
|
||||||
title: "Northwest Regional 2027",
|
|
||||||
theme: "Theme name",
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: "@nw.ngu",
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the regional retreat goes here.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "northwest-2028",
|
|
||||||
title: "Northwest Regional 2028",
|
|
||||||
theme: "Theme name",
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: "@nw.ngu",
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the regional retreat goes here.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "partner",
|
|
||||||
title: "Partner Events",
|
|
||||||
blurb: "Events hosted by organizations we collaborate with.",
|
|
||||||
accent: "#7a5ea8",
|
|
||||||
defaultColor: "#7a5ea8",
|
|
||||||
defaultView: "grid",
|
|
||||||
background: "#eef9fb",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: "partner-example-1",
|
|
||||||
title: "Partner Event Name",
|
|
||||||
theme: null,
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: null,
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the partner event goes here.",
|
|
||||||
desc_b: "Hosted by Partner Organization.",
|
|
||||||
status: "past",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
link: "partner-link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "partner-example-2",
|
|
||||||
title: "Partner Event Name",
|
|
||||||
theme: null,
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: null,
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the partner event goes here.",
|
|
||||||
desc_b: "Hosted by Partner Organization.",
|
|
||||||
status: "past",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
link: "partner-link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "partner-example-3",
|
|
||||||
title: "Partner Event Name",
|
|
||||||
theme: null,
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: null,
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the partner event goes here.",
|
|
||||||
desc_b: "Hosted by Partner Organization.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
link: "partner-link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "partner-example-4",
|
|
||||||
title: "Partner Event Name",
|
|
||||||
theme: null,
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: null,
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the partner event goes here.",
|
|
||||||
desc_b: "Hosted by Partner Organization.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
link: "partner-link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "partner-example-5",
|
|
||||||
title: "Partner Event Name",
|
|
||||||
theme: null,
|
|
||||||
date: "Date",
|
|
||||||
location: "Location",
|
|
||||||
org_logo: null,
|
|
||||||
image: null,
|
|
||||||
instagram: null,
|
|
||||||
color: null,
|
|
||||||
gradient: null,
|
|
||||||
desc_a: "Short description of the partner event goes here.",
|
|
||||||
desc_b: "Hosted by Partner Organization.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
label: "Learn More",
|
|
||||||
link: "partner-link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
export default eventsData;
|
|
||||||
51
src/data/feedbackTypes.ts
Normal file
51
src/data/feedbackTypes.ts
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);
|
||||||
|
}
|
||||||
63
src/data/historyDecades.ts
Normal file
63
src/data/historyDecades.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* Decade headers for the history page.
|
||||||
|
*
|
||||||
|
* Not in the database on purpose. There are four of them, they change
|
||||||
|
* about never, and they're editorial voice rather than record — the same
|
||||||
|
* reasoning that keeps the map grid config in code. A table for four
|
||||||
|
* rows that nobody edits is a migration and an admin page for nothing.
|
||||||
|
*
|
||||||
|
* `preProgram` is what draws the gap: a dashed rail, hollow year dots,
|
||||||
|
* and the "before the program" marker. It lives on the decade rather
|
||||||
|
* than being a hardcoded year check, so the gap moves if the founding
|
||||||
|
* date is ever revised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DecadeMeta } from '../lib/timeline'
|
||||||
|
|
||||||
|
export const HISTORY_DECADES: DecadeMeta[] = [
|
||||||
|
{
|
||||||
|
decade: 2020,
|
||||||
|
title: 'The Age of Autonomy',
|
||||||
|
tagline: 'Growth, unprecedented support, returning to our roots',
|
||||||
|
blurb:
|
||||||
|
'Covid-19 sends everyone home for 2 years and from the resurgence comes a new iteration of the program',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 2010,
|
||||||
|
title: 'Millennials run the show',
|
||||||
|
tagline: 'A time of something',
|
||||||
|
blurb:
|
||||||
|
'Need to update what happened here, mostly documented on facebook we asumme',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 2000,
|
||||||
|
title: 'NGU - The new acronym',
|
||||||
|
tagline: 'Turn of the century forms a national explosion',
|
||||||
|
blurb:
|
||||||
|
'Unity\'s young adult program becomes Next Generation of Unity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1990,
|
||||||
|
title: 'Before NGU there was YAU',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1980,
|
||||||
|
title: 'YAU?',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decade: 1970,
|
||||||
|
title: 'YAU?',
|
||||||
|
tagline: 'Young adult ministry existed',
|
||||||
|
preProgram: true,
|
||||||
|
blurb:
|
||||||
|
'Anything listed here predates our named program. We are looking to document this history more',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -121,9 +121,9 @@ export function areaForChapter(chapter) {
|
||||||
regionAreas [{ region_id, area_code, share, edge, note }]
|
regionAreas [{ region_id, area_code, share, edge, note }]
|
||||||
regions [{ id, name, color, ... }]
|
regions [{ id, name, color, ... }]
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function buildAreaSlices(regionAreas = [], regions = []) {
|
export function buildAreaSlices(regionAreas: any[] = [], regions: any[] = []) {
|
||||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||||
const byArea = {};
|
const byArea: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const row of regionAreas) {
|
for (const row of regionAreas) {
|
||||||
const area = AREA_BY_CODE[row.area_code];
|
const area = AREA_BY_CODE[row.area_code];
|
||||||
|
|
@ -151,7 +151,7 @@ export function buildAreaSlices(regionAreas = [], regions = []) {
|
||||||
/* ── Chapter counts per tile ───────────────────────────────────
|
/* ── Chapter counts per tile ───────────────────────────────────
|
||||||
{ WA: 2, MO: 1, CANADA: 1 }
|
{ WA: 2, MO: 1, CANADA: 1 }
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function countChaptersByArea(chapters = []) {
|
export function countChaptersByArea(chapters: any[] = []) {
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const chapter of chapters) {
|
for (const chapter of chapters) {
|
||||||
const code = areaForChapter(chapter);
|
const code = areaForChapter(chapter);
|
||||||
|
|
@ -165,7 +165,7 @@ export function countChaptersByArea(chapters = []) {
|
||||||
from the row rather than being looked up in a splits table and
|
from the row rather than being looked up in a splits table and
|
||||||
branched on whether this region is the primary.
|
branched on whether this region is the primary.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function areasLabel(regionId, regionAreas = []) {
|
export function areasLabel(regionId, regionAreas: any[] = []) {
|
||||||
return regionAreas
|
return regionAreas
|
||||||
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
||||||
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
||||||
299
src/data/old-historyMock.ts
Normal file
299
src/data/old-historyMock.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
/**
|
||||||
|
* PLACEHOLDER DATA — delete this file once `GET /api/history` lands.
|
||||||
|
*
|
||||||
|
* Shaped exactly like the aggregation route's payload, so swapping it out
|
||||||
|
* is a two-line change in History.tsx.
|
||||||
|
*
|
||||||
|
* Note what is and isn't here. Entries that point at a record carry a
|
||||||
|
* `ref` and little else: no venue, no host name, no copy that also lives
|
||||||
|
* in `events`. The few that do carry a title are overriding it on
|
||||||
|
* purpose. Titles and blurbs below are invented scaffolding, not real NGU
|
||||||
|
* history — replace them before anyone sees this.
|
||||||
|
*
|
||||||
|
* Decade headers are not here — they live in historyDecades.ts and stay
|
||||||
|
* in code even after the API lands.
|
||||||
|
*
|
||||||
|
* Upcoming items are not flagged. `partitionByDate` decides what's
|
||||||
|
* upcoming by comparing dates to the wall clock, so nothing here needs
|
||||||
|
* editing as dates pass.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TimelineItem } from '../lib/timeline'
|
||||||
|
|
||||||
|
export const MOCK_ITEMS: TimelineItem[] = [
|
||||||
|
// ——— Upcoming (relative to the wall clock, not a flag) ———
|
||||||
|
{
|
||||||
|
id: 'tl-0001',
|
||||||
|
date: '2027-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference 2027',
|
||||||
|
meta: 'Unity Village, MO',
|
||||||
|
ref: { kind: 'event', id: 'summer-2027' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0002',
|
||||||
|
date: '2026-11-14',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Fall Regional Rally',
|
||||||
|
meta: 'Southeast',
|
||||||
|
ref: { kind: 'event', id: 'fall-rally-2026' },
|
||||||
|
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0003',
|
||||||
|
date: '2026-10-03',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Chapter Leads Intensive',
|
||||||
|
meta: 'Online',
|
||||||
|
ref: { kind: 'event', id: 'leads-intensive-2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2020s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0010',
|
||||||
|
date: '2026-03',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'organization',
|
||||||
|
title: 'Twentieth chapter chartered',
|
||||||
|
blurb:
|
||||||
|
'The first new charter in the region since 2017, started by three people who met at a rally two summers earlier.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'organization', id: 'boise', orgKind: 'chapter' },
|
||||||
|
logo: { file: 'boise.svg', kind: 'organization' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0011',
|
||||||
|
date: '2026-04-17',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Spring Regional Rally',
|
||||||
|
ref: { kind: 'event', id: 'spring-rally-2026' },
|
||||||
|
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0012',
|
||||||
|
date: '2026-01',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'people',
|
||||||
|
title: 'New leadership team seated',
|
||||||
|
blurb: 'A four-person exec on a two-year term, the first under the 2024 bylaws.',
|
||||||
|
ref: { kind: 'team', id: 'ngu-leadership' },
|
||||||
|
team: { id: 'ngu-leadership', name: 'Leadership Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||||
|
people: [
|
||||||
|
{ id: 'jane-doe', name: 'Jane Doe', title: 'Chair', photo: 'jane-doe.jpg' },
|
||||||
|
{ id: 'sam-ruiz', name: 'Sam Ruiz', title: 'Vice Chair', photo: 'sam-ruiz.jpg' },
|
||||||
|
{ id: 'ada-mensah', name: 'Ada Mensah', title: 'Secretary' },
|
||||||
|
{ id: 'tom-baird', name: 'Tom Baird', title: 'Treasurer', photo: 'tom-baird.jpg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0013',
|
||||||
|
date: '2025-12-28',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Winter Gathering',
|
||||||
|
ref: { kind: 'event', id: 'winter-gathering-2025' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0014',
|
||||||
|
date: '2025-07-19',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'Service Award',
|
||||||
|
meta: 'Presented to the Southeast chapter leads',
|
||||||
|
ref: { kind: 'award', id: 'service' },
|
||||||
|
logo: { file: 'service-award.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0015',
|
||||||
|
date: '2025-07-17',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
meta: 'Three days, 140 attendees',
|
||||||
|
ref: { kind: 'event', id: 'summer-2025' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0016',
|
||||||
|
date: '2025',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Photo archive digitized',
|
||||||
|
blurb: 'Roughly 4,000 images from 2003 onward, scanned by volunteers.',
|
||||||
|
href: 'https://archive.nextgenerationofunity.org',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0017',
|
||||||
|
date: '2024-09',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Bylaws rewritten',
|
||||||
|
blurb:
|
||||||
|
'Term limits, a defined handoff window, and the first written process for chartering a chapter.',
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0018',
|
||||||
|
date: '2024-07-11',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
ref: { kind: 'event', id: 'summer-2024' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0019',
|
||||||
|
date: '2024-07-13',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'Emerging Leader Award',
|
||||||
|
meta: 'First year the award was given',
|
||||||
|
ref: { kind: 'award', id: 'emerging-leader' },
|
||||||
|
logo: { file: 'emerging-leader.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0020',
|
||||||
|
date: '2022-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'First in-person rally since 2019',
|
||||||
|
blurb: 'Sixty-one people, most of whom had only ever met on a video call.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'event', id: 'return-rally-2022' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0021',
|
||||||
|
date: '2021-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Online Summer Intensive',
|
||||||
|
meta: 'Six sessions across two weeks',
|
||||||
|
ref: { kind: 'event', id: 'online-intensive-2021' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0022',
|
||||||
|
date: '2020-03',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'All gatherings suspended',
|
||||||
|
blurb: 'Weekly online rooms started the following week and ran for 118 weeks.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2010s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0030',
|
||||||
|
date: '2018-05',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'organization',
|
||||||
|
title: 'Eighth region recognized',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'organization', id: 'northwest', orgKind: 'region' },
|
||||||
|
logo: { file: 'northwest.svg', kind: 'organization' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0031',
|
||||||
|
date: '2018-07-20',
|
||||||
|
precision: 'day',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
ref: { kind: 'event', id: 'summer-2018' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0032',
|
||||||
|
date: '2016-02',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'people',
|
||||||
|
title: 'Retreat Team formed',
|
||||||
|
blurb:
|
||||||
|
'Programming had been whoever volunteered. This made it a standing team with a handoff.',
|
||||||
|
ref: { kind: 'team', id: 'ngu-retreat-team' },
|
||||||
|
team: { id: 'ngu-retreat-team', name: 'Retreat Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||||
|
people: [
|
||||||
|
{ id: 'marcus-hale', name: 'Marcus Hale', title: 'Founding lead', photo: 'marcus-hale.jpg' },
|
||||||
|
{ id: 'priya-nair', name: 'Priya Nair', photo: 'priya-nair.jpg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0033',
|
||||||
|
date: '2015-07',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'award',
|
||||||
|
title: 'First Service Award presented',
|
||||||
|
blurb: 'Created to name the work that had been going unnamed for a decade.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'award', id: 'service' },
|
||||||
|
logo: { file: 'service-award.svg', kind: 'award' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0034',
|
||||||
|
date: '2015-02',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Shared calendar goes live',
|
||||||
|
blurb: 'Regions stop scheduling on top of each other.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0035',
|
||||||
|
date: '2012-06',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Summer Conference',
|
||||||
|
meta: 'The year attendance first passed 100',
|
||||||
|
ref: { kind: 'event', id: 'summer-2012' },
|
||||||
|
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0036',
|
||||||
|
date: '2012',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Adopted the name Next Generation of Unity',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— 2000s ———
|
||||||
|
{
|
||||||
|
id: 'tl-0040',
|
||||||
|
date: '2009',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Four regions drawn on a map for the first time',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0041',
|
||||||
|
date: '2005-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'The gathering becomes annual',
|
||||||
|
blurb: 'Before this it happened when someone had the energy to organize it.',
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tl-0042',
|
||||||
|
date: '2002-08',
|
||||||
|
precision: 'month',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'First young adult retreat',
|
||||||
|
meta: 'Nineteen attendees',
|
||||||
|
blurb:
|
||||||
|
'Organized over six weeks at a borrowed retreat center. Everything else on this page follows from it.',
|
||||||
|
featured: true,
|
||||||
|
ref: { kind: 'event', id: 'first-retreat-2002' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ——— Pre-program ———
|
||||||
|
{
|
||||||
|
id: 'tl-0050',
|
||||||
|
date: '1997',
|
||||||
|
precision: 'year',
|
||||||
|
kind: 'milestone',
|
||||||
|
title: 'Regional youth programs running independently',
|
||||||
|
blurb:
|
||||||
|
'Not NGU, and not connected to each other — but the people who started NGU came out of these.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
chapter { region_id, region_name, region_color, meets, started }
|
chapter { region_id, region_name, region_color, meets, started }
|
||||||
partner {}
|
partner {}
|
||||||
|
|
||||||
Each kind is a separate request path, so the cache in api.js
|
Each kind is a separate request path, so the cache in api.ts
|
||||||
keys them apart and two sections asking for regions share one
|
keys them apart and two sections asking for regions share one
|
||||||
fetch.
|
fetch.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useResource } from "../lib/useResource.js";
|
import { useResource } from "../lib/useResource.ts";
|
||||||
|
|
||||||
const EMPTY = { organizations: [] };
|
const EMPTY = { organizations: [] };
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function useOrganizations(kind) {
|
||||||
? `/organizations?kind=${encodeURIComponent(kind)}`
|
? `/organizations?kind=${encodeURIComponent(kind)}`
|
||||||
: "/organizations";
|
: "/organizations";
|
||||||
|
|
||||||
const { data, error, loading } = useResource(path, { fallback: EMPTY });
|
const { data, error, loading } = useResource(path);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
organizations: data?.organizations ?? [],
|
organizations: data?.organizations ?? [],
|
||||||
|
|
@ -69,7 +69,7 @@ export function orgPath(org) {
|
||||||
any more.
|
any more.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function areasSentence(areas = []) {
|
export function areasSentence(areas: any[] = []) {
|
||||||
return areas
|
return areas
|
||||||
.filter((area) => area.area_code !== "CANADA")
|
.filter((area) => area.area_code !== "CANADA")
|
||||||
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
||||||
1155
src/lib/adminSchema.ts
Normal file
1155
src/lib/adminSchema.ts
Normal file
File diff suppressed because it is too large
Load diff
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<any>(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]);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,10 @@ const DEFAULT_TTL = 60_000;
|
||||||
const cache = new Map(); // path → { at, promise }
|
const cache = new Map(); // path → { at, promise }
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(message, { status, fields } = {}) {
|
status;
|
||||||
|
fields;
|
||||||
|
|
||||||
|
constructor(message, { status, fields }: { status?: number; fields?: any } = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
|
@ -33,7 +36,7 @@ export class ApiError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options: RequestInit = {}) {
|
||||||
const response = await fetch(`${BASE}${path}`, {
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
headers: { Accept: "application/json", ...options.headers },
|
headers: { Accept: "application/json", ...options.headers },
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -68,7 +71,7 @@ async function request(path, options = {}) {
|
||||||
get("/events", { fallback }) → fallback on any failure
|
get("/events", { fallback }) → fallback on any failure
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
|
export function get(path, { ttl = DEFAULT_TTL, fallback }: { ttl?: number; fallback?: any } = {}) {
|
||||||
const hit = cache.get(path);
|
const hit = cache.get(path);
|
||||||
|
|
||||||
if (hit && Date.now() - hit.at < ttl) return hit.promise;
|
if (hit && Date.now() - hit.at < ttl) return hit.promise;
|
||||||
|
|
@ -100,3 +103,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.ts";
|
||||||
|
|
||||||
|
const AuthContext = createContext<any>(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 />;
|
||||||
|
}
|
||||||
62
src/lib/embeds.ts
Normal file
62
src/lib/embeds.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EMBEDS
|
||||||
|
|
||||||
|
Turns the link an admin pastes into the URL an <iframe> can
|
||||||
|
load. People paste the page they're looking at — a YouTube watch
|
||||||
|
or live link, a Facebook video, a Vimeo page — not the embed
|
||||||
|
form, so those are recognised and rewritten. Anything else that
|
||||||
|
is https is assumed to already be an embed URL and passed
|
||||||
|
through; anything that isn't https is refused, so the hero never
|
||||||
|
frames a plain-http or javascript: URL.
|
||||||
|
|
||||||
|
Streams start muted: browsers only autoplay muted video, and a
|
||||||
|
hero that starts shouting is worse than one that asks.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function youtubeId(url: URL): string | null {
|
||||||
|
const host = url.hostname.replace(/^www\.|^m\./, '')
|
||||||
|
if (host === 'youtu.be') return url.pathname.slice(1) || null
|
||||||
|
if (host !== 'youtube.com' && host !== 'youtube-nocookie.com') return null
|
||||||
|
|
||||||
|
const v = url.searchParams.get('v')
|
||||||
|
if (v) return v
|
||||||
|
|
||||||
|
// /live/ID, /embed/ID, /shorts/ID
|
||||||
|
const match = url.pathname.match(/^\/(?:live|embed|shorts)\/([\w-]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An iframe src for a pasted stream link, or null if it can't be framed. */
|
||||||
|
export function livestreamEmbedUrl(raw?: string | null): string | null {
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:') return null
|
||||||
|
|
||||||
|
const yt = youtubeId(url)
|
||||||
|
if (yt) {
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(yt)}?autoplay=1&mute=1&playsinline=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = url.hostname.replace(/^www\./, '')
|
||||||
|
|
||||||
|
if (host === 'vimeo.com') {
|
||||||
|
const id = url.pathname.match(/^\/(?:event\/)?(\d+)/)?.[1]
|
||||||
|
if (id) {
|
||||||
|
return url.pathname.startsWith('/event/')
|
||||||
|
? `https://vimeo.com/event/${id}/embed?autoplay=1&muted=1`
|
||||||
|
: `https://player.vimeo.com/video/${id}?autoplay=1&muted=1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((host === 'facebook.com' || host === 'fb.watch') && !url.pathname.startsWith('/plugins/')) {
|
||||||
|
return `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url.href)}&autoplay=1&mute=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
292
src/lib/eventSeries.ts
Normal file
292
src/lib/eventSeries.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT SERIES
|
||||||
|
|
||||||
|
An event with is_series set meets on a schedule rather than once.
|
||||||
|
The API sends the schedule as-is (shapeSeries in content.js); this
|
||||||
|
file is the one place that turns it into words and dates, so the
|
||||||
|
cards and the detail page can't describe the same series two ways.
|
||||||
|
|
||||||
|
Occurrences are derived, never stored. starts_on is the first
|
||||||
|
meeting and the anchor: it fixes which weeks an every-other-week
|
||||||
|
series is "on", which day a monthly one keeps, and the weekday
|
||||||
|
when none is ticked. ends_on, when set, is the last day it can
|
||||||
|
meet; count, when set, stops it after that many meetings.
|
||||||
|
|
||||||
|
Dates are 'YYYY-MM-DD' and are handled as UTC midnights so that
|
||||||
|
stepping a day never lands on a DST gap. They are calendar dates,
|
||||||
|
not instants — nothing here converts timezones.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export type SeriesFrequency = 'weekly' | 'monthly_date' | 'monthly_weekday'
|
||||||
|
|
||||||
|
export type SeriesWeekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
|
||||||
|
|
||||||
|
export type EventSeries = {
|
||||||
|
frequency: SeriesFrequency
|
||||||
|
interval: number
|
||||||
|
/** Ticked days, Sunday first. Empty means starts_on's weekday. */
|
||||||
|
weekdays: SeriesWeekday[]
|
||||||
|
/** 'HH:MM', 24-hour. */
|
||||||
|
start_time?: string | null
|
||||||
|
end_time?: string | null
|
||||||
|
count?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many upcoming meetings the event page lists. */
|
||||||
|
export const SERIES_UPCOMING_SHOWN = 6
|
||||||
|
|
||||||
|
/* Past this many meetings the walk stops, whatever the schedule
|
||||||
|
says. Twenty years of a daily-ish weekly series is well inside
|
||||||
|
it; an open-ended series with a start date decades back is what
|
||||||
|
it's for. */
|
||||||
|
const MAX_OCCURRENCES = 5000
|
||||||
|
|
||||||
|
/* Index is Date#getUTCDay. */
|
||||||
|
const WEEKDAYS: SeriesWeekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
||||||
|
const WEEKDAY_NAMES = [
|
||||||
|
'Sunday',
|
||||||
|
'Monday',
|
||||||
|
'Tuesday',
|
||||||
|
'Wednesday',
|
||||||
|
'Thursday',
|
||||||
|
'Friday',
|
||||||
|
'Saturday',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
/* ── Dates ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function parseDate(value?: string | null): Date | null {
|
||||||
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||||
|
const date = new Date(`${value}T00:00:00Z`)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
/* The viewer's today, as a calendar date. */
|
||||||
|
function today(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = (year: number, month: number) =>
|
||||||
|
new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||||
|
|
||||||
|
/* 1st–4th, or 5 for a date in the month's fifth week, which the
|
||||||
|
schedule treats as "last" — every month has a last Tuesday, not
|
||||||
|
every month has a fifth. */
|
||||||
|
const weekOfMonth = (date: Date) => Math.ceil(date.getUTCDate() / 7)
|
||||||
|
|
||||||
|
function nthWeekday(year: number, month: number, weekday: number, nth: number): Date {
|
||||||
|
if (nth >= 5) {
|
||||||
|
const last = new Date(Date.UTC(year, month, daysInMonth(year, month)))
|
||||||
|
const back = (last.getUTCDay() - weekday + 7) % 7
|
||||||
|
return new Date(last.getTime() - back * DAY_MS)
|
||||||
|
}
|
||||||
|
const first = new Date(Date.UTC(year, month, 1))
|
||||||
|
const ahead = (weekday - first.getUTCDay() + 7) % 7
|
||||||
|
return new Date(Date.UTC(year, month, 1 + ahead + (nth - 1) * 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Occurrences ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Every meeting date in schedule order, lazily, bounded by ends_on,
|
||||||
|
count and MAX_OCCURRENCES. */
|
||||||
|
function* occurrences(
|
||||||
|
series: EventSeries,
|
||||||
|
startsOn?: string | null,
|
||||||
|
endsOn?: string | null,
|
||||||
|
): Generator<string> {
|
||||||
|
const start = parseDate(startsOn)
|
||||||
|
if (!start) return
|
||||||
|
|
||||||
|
const end = parseDate(endsOn)
|
||||||
|
const limit = Math.min(series.count ?? MAX_OCCURRENCES, MAX_OCCURRENCES)
|
||||||
|
const interval = Math.max(1, series.interval || 1)
|
||||||
|
let emitted = 0
|
||||||
|
|
||||||
|
const within = (date: Date) => !end || date.getTime() <= end.getTime()
|
||||||
|
|
||||||
|
if (series.frequency === 'weekly') {
|
||||||
|
const days = new Set(
|
||||||
|
series.weekdays.length
|
||||||
|
? series.weekdays.map((day) => WEEKDAYS.indexOf(day))
|
||||||
|
: [start.getUTCDay()],
|
||||||
|
)
|
||||||
|
// Weeks run Sunday to Saturday and are numbered from the one
|
||||||
|
// starts_on falls in, so "every 2 weeks" means that week, the
|
||||||
|
// week after next, and so on.
|
||||||
|
const weekZero = start.getTime() - start.getUTCDay() * DAY_MS
|
||||||
|
|
||||||
|
for (let t = start.getTime(); emitted < limit; t += DAY_MS) {
|
||||||
|
const date = new Date(t)
|
||||||
|
if (!within(date)) return
|
||||||
|
const week = Math.floor((t - weekZero) / (7 * DAY_MS))
|
||||||
|
if (week % interval === 0 && days.has(date.getUTCDay())) {
|
||||||
|
yield isoDate(date)
|
||||||
|
emitted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = start.getUTCFullYear()
|
||||||
|
const month = start.getUTCMonth()
|
||||||
|
const day = start.getUTCDate()
|
||||||
|
const weekday = start.getUTCDay()
|
||||||
|
const nth = weekOfMonth(start)
|
||||||
|
|
||||||
|
for (let step = 0; emitted < limit; step += 1) {
|
||||||
|
const offset = month + step * interval
|
||||||
|
const y = year + Math.floor(offset / 12)
|
||||||
|
const m = offset % 12
|
||||||
|
const date =
|
||||||
|
series.frequency === 'monthly_weekday'
|
||||||
|
? nthWeekday(y, m, weekday, nth)
|
||||||
|
: new Date(Date.UTC(y, m, Math.min(day, daysInMonth(y, m))))
|
||||||
|
if (!within(date)) return
|
||||||
|
yield isoDate(date)
|
||||||
|
emitted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next meetings from today on, soonest first. */
|
||||||
|
export function upcomingOccurrences(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn?: string | null,
|
||||||
|
endsOn?: string | null,
|
||||||
|
limit = SERIES_UPCOMING_SHOWN,
|
||||||
|
): string[] {
|
||||||
|
if (!series) return []
|
||||||
|
const from = today()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||||
|
if (date < from) continue
|
||||||
|
out.push(date)
|
||||||
|
if (out.length >= limit) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every meeting between two dates, inclusive, in order. For a
|
||||||
|
* calendar page: `from` and `to` are the visible range. */
|
||||||
|
export function occurrencesBetween(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn: string | null | undefined,
|
||||||
|
endsOn: string | null | undefined,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): string[] {
|
||||||
|
if (!series) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||||
|
if (date > to) break
|
||||||
|
if (date >= from) out.push(date)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first meeting on or after `from`, or null if the series has
|
||||||
|
* ended by then. */
|
||||||
|
export function firstOccurrenceFrom(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn: string | null | undefined,
|
||||||
|
endsOn: string | null | undefined,
|
||||||
|
from: string,
|
||||||
|
): string | null {
|
||||||
|
if (!series) return null
|
||||||
|
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||||
|
if (date >= from) return date
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Words ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
|
||||||
|
|
||||||
|
function ordinalDay(n: number): string {
|
||||||
|
const tens = n % 100
|
||||||
|
if (tens >= 11 && tens <= 13) return `${n}th`
|
||||||
|
return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinWords(words: string[]): string {
|
||||||
|
if (words.length <= 1) return words[0] ?? ''
|
||||||
|
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clock(value?: string | null): string | null {
|
||||||
|
const match = value?.match(/^(\d{2}):(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])))
|
||||||
|
return date.toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "7:00 PM – 8:30 PM", "7:00 PM", or null. */
|
||||||
|
export function seriesTimes(series: EventSeries | null | undefined): string | null {
|
||||||
|
if (!series) return null
|
||||||
|
const from = clock(series.start_time)
|
||||||
|
const to = clock(series.end_time)
|
||||||
|
if (from && to) return `${from} – ${to}`
|
||||||
|
return from ?? to
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Every Tuesday and Thursday, 7:00 PM – 8:30 PM",
|
||||||
|
* "Every 2 weeks on Monday", "Monthly on the 2nd Tuesday",
|
||||||
|
* "Every 3 months on the 13th". Null for a one-off event, or a
|
||||||
|
* series with no start date to anchor it.
|
||||||
|
*/
|
||||||
|
export function seriesLabel(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn?: string | null,
|
||||||
|
): string | null {
|
||||||
|
const start = parseDate(startsOn)
|
||||||
|
if (!series || !start) return null
|
||||||
|
|
||||||
|
const interval = Math.max(1, series.interval || 1)
|
||||||
|
let pattern: string
|
||||||
|
|
||||||
|
if (series.frequency === 'weekly') {
|
||||||
|
const days = series.weekdays.length
|
||||||
|
? series.weekdays.map((day) => WEEKDAY_NAMES[WEEKDAYS.indexOf(day)])
|
||||||
|
: [WEEKDAY_NAMES[start.getUTCDay()]]
|
||||||
|
pattern =
|
||||||
|
interval === 1
|
||||||
|
? `Every ${joinWords(days)}`
|
||||||
|
: `Every ${interval === 2 ? 'other week' : `${interval} weeks`} on ${joinWords(days)}`
|
||||||
|
} else {
|
||||||
|
const on =
|
||||||
|
series.frequency === 'monthly_weekday'
|
||||||
|
? `the ${ORDINALS[weekOfMonth(start)]} ${WEEKDAY_NAMES[start.getUTCDay()]}`
|
||||||
|
: `the ${ordinalDay(start.getUTCDate())}`
|
||||||
|
pattern =
|
||||||
|
interval === 1
|
||||||
|
? `Monthly on ${on}`
|
||||||
|
: `Every ${interval === 2 ? 'other month' : `${interval} months`} on ${on}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = seriesTimes(series)
|
||||||
|
return times ? `${pattern}, ${times}` : pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '2026-10-13' → 'Tue, Oct 13, 2026'. */
|
||||||
|
export function occurrenceLabel(date: string): string {
|
||||||
|
const parsed = parseDate(date)
|
||||||
|
if (!parsed) return date
|
||||||
|
return parsed.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
}
|
||||||
46
src/lib/eventTypes.ts
Normal file
46
src/lib/eventTypes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT TYPES
|
||||||
|
|
||||||
|
The client half of the CHECK on events.event_type. Order here is
|
||||||
|
display order — the filter chips read it straight off this array,
|
||||||
|
so moving a line moves a chip.
|
||||||
|
|
||||||
|
What this is not: event_scopes. A scope owns presentation —
|
||||||
|
Retreats.tsx keys its title, accent and background on the id, so
|
||||||
|
an unrecognised scope_id makes an event vanish with no error,
|
||||||
|
which is why that one is a real table with a real foreign key. A
|
||||||
|
type carries no presentation of its own and an unknown value
|
||||||
|
renders as its own name, so a CHECK is enough.
|
||||||
|
|
||||||
|
Adding a type is three edits: the CHECK in a migration, the enum
|
||||||
|
in both descriptor halves, and this list. Adding it here alone
|
||||||
|
means the site offers a filter the database will refuse to store.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other'
|
||||||
|
|
||||||
|
export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [
|
||||||
|
{ id: 'retreat', label: 'Retreat', plural: 'Retreats' },
|
||||||
|
{ id: 'class', label: 'Class', plural: 'Classes' },
|
||||||
|
{ id: 'workshop', label: 'Workshop', plural: 'Workshops' },
|
||||||
|
{ id: 'meeting', label: 'Meeting', plural: 'Meetings' },
|
||||||
|
{ id: 'other', label: 'Other', plural: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id)
|
||||||
|
|
||||||
|
const BY_ID = new Map<string, { label: string; plural: string }>(
|
||||||
|
EVENT_TYPES.map((entry) => [entry.id as string, entry]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const capitalize = (word: string) =>
|
||||||
|
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||||
|
|
||||||
|
/* A value the CHECK has gained since this file was written renders
|
||||||
|
as itself rather than vanishing — the same rule EventDetail's
|
||||||
|
ROLE_ORDER follows for billing roles. */
|
||||||
|
export const eventTypeLabel = (id?: string | null): string =>
|
||||||
|
(id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '')
|
||||||
|
|
||||||
|
export const eventTypePlural = (id?: string | null): string =>
|
||||||
|
(id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '')
|
||||||
174
src/lib/hrefs.ts
Normal file
174
src/lib/hrefs.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PUBLIC HREFS
|
||||||
|
|
||||||
|
One place that turns a record into a URL. The API deliberately
|
||||||
|
never sends paths — it sends `{ kind, id }` and, for an
|
||||||
|
organization, the `org_kind` that decides which of the three
|
||||||
|
routes a slug belongs to. Deciding that in each component is how
|
||||||
|
/regions/x and /chapters/x end up both existing for the same
|
||||||
|
record, and how the history timeline ended up emitting /event/:id
|
||||||
|
while the router only knew /retreats/:id.
|
||||||
|
|
||||||
|
timelineRefs.ts now delegates here rather than keeping its own
|
||||||
|
table, so there is one answer to "where does an event live" and
|
||||||
|
changing it is changing EVENT_BASE below.
|
||||||
|
|
||||||
|
navConfig.ts stays the source of truth for the *nav*: these are
|
||||||
|
record routes, which never appear in it.
|
||||||
|
|
||||||
|
── On missing ids ──
|
||||||
|
Every builder takes an id the caller believed it had. When it
|
||||||
|
doesn't, the old behaviour was to interpolate the string
|
||||||
|
"undefined" into a path, render a link to it, mount a page and
|
||||||
|
fetch /api/organizations/undefined — four steps between the
|
||||||
|
mistake and any sign of it, none of which name the component
|
||||||
|
that made it.
|
||||||
|
|
||||||
|
Now the id is checked here. In dev that's a console.error with a
|
||||||
|
stack trace pointing at the caller; in production the path still
|
||||||
|
comes out, because a broken link beats a crashed page, and
|
||||||
|
useResource refuses to fetch it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
|
||||||
|
|
||||||
|
/* A region, a chapter and a partner read as different things to a
|
||||||
|
visitor even though they are one table, so they get one route
|
||||||
|
each. 'national' is NGU itself — one record, no listing to sit
|
||||||
|
under, so it falls through to the generic path. */
|
||||||
|
const ORG_BASE: Record<string, string> = {
|
||||||
|
region: '/regions',
|
||||||
|
chapter: '/chapters',
|
||||||
|
partner: '/partners',
|
||||||
|
national: '/organizations',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical base for an event page.
|
||||||
|
*
|
||||||
|
* /events/:id, not /retreats/:id. The listing page is called
|
||||||
|
* Retreats because that's what NGU calls the gatherings it hosts,
|
||||||
|
* but the records are `events`, the API route is /api/events, and
|
||||||
|
* plenty of them — partner events, conferences — aren't retreats
|
||||||
|
* at all. Naming the record route after one page's editorial
|
||||||
|
* framing would have been wrong the first time a non-retreat got
|
||||||
|
* its own page.
|
||||||
|
*
|
||||||
|
* Nothing redirects from /retreats/:id, because nothing ever
|
||||||
|
* linked there. */
|
||||||
|
export const EVENT_BASE = '/events'
|
||||||
|
|
||||||
|
const DEV = Boolean((import.meta as any)?.env?.DEV)
|
||||||
|
|
||||||
|
/* Router params arrive as strings, so an id that has already been
|
||||||
|
through a template literal shows up as the literal word. Those
|
||||||
|
are as broken as a genuine null. */
|
||||||
|
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
|
||||||
|
|
||||||
|
export const isBadId = (id: unknown): boolean =>
|
||||||
|
id == null || BAD.has(String(id))
|
||||||
|
|
||||||
|
function checkId(id: unknown, what: string): string {
|
||||||
|
if (!isBadId(id)) return String(id)
|
||||||
|
|
||||||
|
if (DEV) {
|
||||||
|
// console.error rather than warn: this is always a bug, and the
|
||||||
|
// stack is the whole point — it names the component that passed
|
||||||
|
// nothing.
|
||||||
|
console.error(
|
||||||
|
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
|
||||||
|
`The link it returns will 404. Caller:`,
|
||||||
|
new Error('hrefs: missing id').stack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API path for one record, or null when the id isn't usable.
|
||||||
|
*
|
||||||
|
* The mirror image of the builders below: they make the URL a
|
||||||
|
* visitor sees, this makes the URL the client fetches, and both
|
||||||
|
* have to agree about what counts as an id.
|
||||||
|
*
|
||||||
|
* It lives here rather than next to the hook that calls it because
|
||||||
|
* it is a path, and paths are this file's job — and because a
|
||||||
|
* component that interpolates a missing route param produces the
|
||||||
|
* literal string "undefined", which the server cannot tell apart
|
||||||
|
* from a slug somebody genuinely typed. It answers 404 either way,
|
||||||
|
* and the log fills with GET /api/organizations/undefined with
|
||||||
|
* nothing to say where it came from.
|
||||||
|
*
|
||||||
|
* Returning null costs a round trip and turns a mystery 404 into
|
||||||
|
* the not-found page, which is what a visitor should see anyway.
|
||||||
|
*/
|
||||||
|
export function detailPath(base: string, id?: string | null): string | null {
|
||||||
|
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventHref = (id?: string | null) =>
|
||||||
|
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
|
||||||
|
|
||||||
|
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
|
||||||
|
|
||||||
|
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
|
||||||
|
|
||||||
|
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
|
||||||
|
|
||||||
|
export function orgHref(id?: string | null, kind?: string | null): string {
|
||||||
|
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For anything holding a polymorphic reference — timeline entries,
|
||||||
|
content blocks — where the kind arrives as data rather than being
|
||||||
|
known at the call site.
|
||||||
|
|
||||||
|
Returns null for a kind with no page AND for a reference with no
|
||||||
|
id, so the caller renders plain text instead of a dead link.
|
||||||
|
This is the one the timeline wants: an entry whose ref didn't
|
||||||
|
resolve should read as text, not as a link to nowhere. */
|
||||||
|
export function refHref(
|
||||||
|
kind: string | null | undefined,
|
||||||
|
id: string | null | undefined,
|
||||||
|
orgKind?: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!kind || isBadId(id)) return null
|
||||||
|
switch (kind) {
|
||||||
|
case 'event':
|
||||||
|
return eventHref(id)
|
||||||
|
case 'organization':
|
||||||
|
return orgHref(id, orgKind)
|
||||||
|
case 'team':
|
||||||
|
return teamHref(id)
|
||||||
|
case 'award':
|
||||||
|
return awardHref(id)
|
||||||
|
case 'person':
|
||||||
|
return personHref(id)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What to call the kind in a breadcrumb or a back link. */
|
||||||
|
export const ORG_KIND_LABEL: Record<string, string> = {
|
||||||
|
national: 'Next Generation of Unity',
|
||||||
|
region: 'Region',
|
||||||
|
chapter: 'Chapter',
|
||||||
|
partner: 'Partner organization',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where "back" goes from a record page. A chapter belongs to
|
||||||
|
/community, a retreat to /retreats. */
|
||||||
|
export function orgListHref(kind?: string | null): { to: string; label: string } {
|
||||||
|
switch (kind) {
|
||||||
|
case 'region':
|
||||||
|
return { to: '/community#local', label: 'All regions' }
|
||||||
|
case 'chapter':
|
||||||
|
return { to: '/community#local', label: 'All chapters' }
|
||||||
|
case 'partner':
|
||||||
|
return { to: '/community#partners', label: 'All partner organizations' }
|
||||||
|
default:
|
||||||
|
return { to: '/community', label: 'Community' }
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/lib/media.ts
Normal file
81
src/lib/media.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
MEDIA PATHS
|
||||||
|
|
||||||
|
Every image field in the API is a bare filename — "where the
|
||||||
|
images live is the component's business", as history.js puts it.
|
||||||
|
This is that business, in one file, so moving a directory is one
|
||||||
|
edit rather than a grep.
|
||||||
|
|
||||||
|
⚠ Only two of these directories are confirmed by the admin help
|
||||||
|
text: people/ and event-logos/. The other three are a guess at
|
||||||
|
your convention. Check public/ and fix them here — nothing else
|
||||||
|
references the paths.
|
||||||
|
|
||||||
|
A value that already looks like a path or a URL is returned
|
||||||
|
untouched, so a hand-written entry can point anywhere.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const ABSOLUTE = /^(https?:|\/|data:)/
|
||||||
|
|
||||||
|
function inDir(dir: string) {
|
||||||
|
return (file?: string | null): string | null => {
|
||||||
|
if (!file) return null
|
||||||
|
if (ABSOLUTE.test(file)) return file
|
||||||
|
return `${dir}/${file}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const personPhoto = inDir('/people') // confirmed
|
||||||
|
export const eventLogo = inDir('/event-logos') // confirmed
|
||||||
|
export const orgLogo = inDir('/org-logos') // ⚠ guess
|
||||||
|
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
||||||
|
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
||||||
|
|
||||||
|
/* front_page_slides.media — the home page hero's photos. New with
|
||||||
|
the Front page editor, which names the directory in its help. */
|
||||||
|
export const heroPhoto = inDir('/front-page')
|
||||||
|
|
||||||
|
/* content_blocks.media, which can be an image on any owner's page,
|
||||||
|
so it can't share a per-entity directory. */
|
||||||
|
export const blockMedia = inDir('/media') // ⚠ guess
|
||||||
|
|
||||||
|
/* ── By record kind ────────────────────────────────────────────
|
||||||
|
The timeline sends `logo: { file, kind }` rather than a path,
|
||||||
|
because v_timeline COALESCEs across five tables and only the
|
||||||
|
ref_kind says which one the filename came from.
|
||||||
|
|
||||||
|
⚠ Three of these directories are the guesses above. Your current
|
||||||
|
timelineRefs.ts already has the real ones — timeline logos render
|
||||||
|
today — so copy them into the map above and delete this note.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const BY_KIND: Record<string, (file?: string | null) => string | null> = {
|
||||||
|
event: eventLogo,
|
||||||
|
organization: orgLogo,
|
||||||
|
team: teamLogo,
|
||||||
|
award: awardLogo,
|
||||||
|
person: personPhoto,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoForKind(
|
||||||
|
kind?: string | null,
|
||||||
|
file?: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!file) return null
|
||||||
|
// An unrecognised kind still renders: a filename with no home
|
||||||
|
// directory is a bug, but a broken <img> says so louder than a
|
||||||
|
// silently absent one.
|
||||||
|
return (BY_KIND[kind ?? ''] ?? blockMedia)(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initials for a photo that is missing or fails to load. Same
|
||||||
|
two-word rule PeopleTiles uses. */
|
||||||
|
export function initials(name = ''): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word[0] || '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
57
src/lib/roles.ts
Normal file
57
src/lib/roles.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ROLES — src/lib/roles.ts
|
||||||
|
|
||||||
|
The same ladder as server/src/auth.js, and it has to stay the
|
||||||
|
same ladder. This copy exists to decide what to draw; the
|
||||||
|
server's copy decides what's allowed. If they ever disagree the
|
||||||
|
worst case is a button that 403s, which is the right way round
|
||||||
|
for them to fail.
|
||||||
|
|
||||||
|
Components should ask canDelete(user), not
|
||||||
|
user.role === "admin". The second form is what silently locked
|
||||||
|
superadmins out of saving when the third role went in: an
|
||||||
|
equality check against a ladder is a bug waiting for the next
|
||||||
|
role to be added, and there's now a fourth.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const;
|
||||||
|
|
||||||
|
export type Role = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export const ROLE_RANK: Record<Role, number> = {
|
||||||
|
viewer: 1,
|
||||||
|
editor: 2,
|
||||||
|
admin: 3,
|
||||||
|
superadmin: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
viewer: "Viewer",
|
||||||
|
editor: "Editor",
|
||||||
|
admin: "Admin",
|
||||||
|
superadmin: "Superadmin",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Each line is what that role adds to the one above it in the
|
||||||
|
list. Read top to bottom, they describe the whole ladder. */
|
||||||
|
export const ROLE_NOTES: Record<Role, string> = {
|
||||||
|
viewer: "Can read everything in the CMS and change nothing.",
|
||||||
|
editor: "Can create and update records. Can't delete anything.",
|
||||||
|
admin: "Can delete records, including feedback.",
|
||||||
|
superadmin: "Can manage accounts, roles and sessions.",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaybeUser = { role?: string | null } | null | undefined;
|
||||||
|
|
||||||
|
/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */
|
||||||
|
export function atLeast(user: MaybeUser, role: Role): boolean {
|
||||||
|
const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0;
|
||||||
|
return have >= ROLE_RANK[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Named for the capability rather than the rank, so call sites
|
||||||
|
read as intent and a future reshuffle of the ladder is one edit
|
||||||
|
here rather than a search for every comparison. */
|
||||||
|
export const canWrite = (user: MaybeUser) => atLeast(user, "editor");
|
||||||
|
export const canDelete = (user: MaybeUser) => atLeast(user, "admin");
|
||||||
|
export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin");
|
||||||
394
src/lib/timeline.ts
Normal file
394
src/lib/timeline.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
/**
|
||||||
|
* Timeline types + grouping.
|
||||||
|
*
|
||||||
|
* This is the contract between the future `GET /api/history` route and the
|
||||||
|
* history page.
|
||||||
|
*
|
||||||
|
* ── Reference, don't duplicate ─────────────────────────────────────────
|
||||||
|
* An entry is a *pointer* to a record plus an optional narrative override.
|
||||||
|
* When the admin panel's "add to timeline" button fires on an event, it
|
||||||
|
* writes a row holding the event's id and nothing else; title, logo and
|
||||||
|
* date are read back from `events` at query time. Editing the event
|
||||||
|
* therefore edits the timeline, and there is no second copy to drift.
|
||||||
|
*
|
||||||
|
* Hand-authored entries — "bylaws rewritten", "the gathering becomes
|
||||||
|
* annual" — carry no ref and supply their own title and blurb. An entry
|
||||||
|
* may also do both: reference an event but override its title, for when
|
||||||
|
* the timeline wants to say something the event card doesn't.
|
||||||
|
*
|
||||||
|
* ── What the server resolves, and what it doesn't ──────────────────────
|
||||||
|
* The server resolves *data*: title, date, logo filename, the members of
|
||||||
|
* a referenced team. It does not resolve *routes* or *asset paths* —
|
||||||
|
* those are presentation, and live in `timelineRefs.ts` so React Router
|
||||||
|
* and the public/ layout stay the frontend's business.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { OrgKind } from './hrefs.ts'
|
||||||
|
|
||||||
|
export type DatePrecision = 'year' | 'month' | 'day'
|
||||||
|
|
||||||
|
/** What an entry is about. Drives the marker and the body layout. */
|
||||||
|
export type TimelineKind =
|
||||||
|
| 'milestone' // free-standing narrative, no record behind it
|
||||||
|
| 'event'
|
||||||
|
| 'organization'
|
||||||
|
| 'award'
|
||||||
|
| 'people' // a team forming, someone joining one
|
||||||
|
|
||||||
|
/** Tables an entry can point at. Mirrors the polymorphic owner_kind
|
||||||
|
* pattern already used by content_blocks and links. */
|
||||||
|
export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team'
|
||||||
|
|
||||||
|
export type TimelineRef = {
|
||||||
|
kind: RefKind
|
||||||
|
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||||
|
id: string
|
||||||
|
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
||||||
|
orgKind?: OrgKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename plus the table it came from; the directory is derived
|
||||||
|
* frontend-side, because asset layout is not database business. */
|
||||||
|
export type TimelineLogo = {
|
||||||
|
file: string
|
||||||
|
kind: RefKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person as they appear in a 'people' entry. Resolved server-side,
|
||||||
|
* whether the entry named a team or listed people directly. */
|
||||||
|
export type PersonRef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** people.photo — filename only. */
|
||||||
|
photo?: string
|
||||||
|
/** Their affiliation title at the time, if it's worth printing. */
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamRef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
orgId?: string
|
||||||
|
orgName?: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineItem = {
|
||||||
|
/** The timeline row's own id, not the referenced record's. */
|
||||||
|
id: string
|
||||||
|
/** "2014" | "2014-06" | "2014-06-12" */
|
||||||
|
date: string
|
||||||
|
/** How much of `date` is trustworthy. Authoritative — a backfilled row
|
||||||
|
* may hold a full date while only the year is actually known. */
|
||||||
|
precision: DatePrecision
|
||||||
|
kind: TimelineKind
|
||||||
|
|
||||||
|
/** Falls back to the referenced record's own name when the row has no
|
||||||
|
* title of its own. Resolved server-side. */
|
||||||
|
title: string
|
||||||
|
blurb?: string
|
||||||
|
/** Secondary line: host org, region, venue, recipient. */
|
||||||
|
meta?: string
|
||||||
|
featured?: boolean
|
||||||
|
|
||||||
|
/** The record this points at. Absent for free-standing milestones. */
|
||||||
|
ref?: TimelineRef
|
||||||
|
/** Explicit link override. Absent → derived from `ref`. Every kind can
|
||||||
|
* carry one; event/organization/award fall back to their own page. */
|
||||||
|
href?: string
|
||||||
|
|
||||||
|
logo?: TimelineLogo
|
||||||
|
|
||||||
|
/** kind === 'people': who the entry is about. Populated from the named
|
||||||
|
* team's current members, or from an explicit person list. */
|
||||||
|
people?: PersonRef[]
|
||||||
|
/** Set when the entry named a team rather than loose people. */
|
||||||
|
team?: TeamRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecadeMeta = {
|
||||||
|
/** 2010, 2020, … */
|
||||||
|
decade: number
|
||||||
|
title: string
|
||||||
|
tagline: string
|
||||||
|
blurb?: string
|
||||||
|
/** Renders the ghosted treatment and the "before NGU" marker. */
|
||||||
|
preProgram?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedMonth = {
|
||||||
|
month: number
|
||||||
|
label: string
|
||||||
|
items: TimelineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedYear = {
|
||||||
|
year: number
|
||||||
|
featured: boolean
|
||||||
|
count: number
|
||||||
|
featuredItems: TimelineItem[]
|
||||||
|
/** Year-precision items — known to be this year, month unknown. */
|
||||||
|
undated: TimelineItem[]
|
||||||
|
months: GroupedMonth[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedDecade = DecadeMeta & {
|
||||||
|
years: GroupedYear[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDirection = 'desc' | 'asc'
|
||||||
|
|
||||||
|
export const MONTH_LABELS = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function decadeOf(year: number): number {
|
||||||
|
return Math.floor(year / 10) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decadeLabel(decade: number): string {
|
||||||
|
return `${decade}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DateParts = { year: number; month: number | null; day: number | null }
|
||||||
|
|
||||||
|
function parseDate(item: TimelineItem): DateParts {
|
||||||
|
const [y, m, d] = item.date.split('-')
|
||||||
|
const year = Number(y)
|
||||||
|
if (!Number.isFinite(year)) {
|
||||||
|
throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`)
|
||||||
|
}
|
||||||
|
if (item.precision === 'year') return { year, month: null, day: null }
|
||||||
|
const month = m ? Number(m) : null
|
||||||
|
if (item.precision === 'month') return { year, month, day: null }
|
||||||
|
return { year, month, day: d ? Number(d) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start of the item's date window, as a sortable YYYY-MM-DD.
|
||||||
|
*
|
||||||
|
* A year-precision item resolves to 1 January, a month-precision one to
|
||||||
|
* the 1st. That makes "is this still upcoming?" answerable for imprecise
|
||||||
|
* dates in the one way that can't surprise anyone: an entry stops being
|
||||||
|
* upcoming as soon as any part of its window has passed. A row dated
|
||||||
|
* only "2027" is upcoming through the end of 2026 and no longer is on
|
||||||
|
* 1 January 2027, even though its real date may be months away.
|
||||||
|
*/
|
||||||
|
export function windowStart(item: TimelineItem): string {
|
||||||
|
const { year, month, day } = parseDate(item)
|
||||||
|
return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(now: Date = new Date()): string {
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split upcoming from recorded, off the wall clock rather than a flag.
|
||||||
|
* Nothing needs flipping when a date passes.
|
||||||
|
*/
|
||||||
|
export function partitionByDate(
|
||||||
|
items: TimelineItem[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): { upcoming: TimelineItem[]; past: TimelineItem[] } {
|
||||||
|
const today = todayISO(now)
|
||||||
|
const upcoming: TimelineItem[] = []
|
||||||
|
const past: TimelineItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (windowStart(item) > today) upcoming.push(item)
|
||||||
|
else past.push(item)
|
||||||
|
}
|
||||||
|
return { upcoming, past }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── grouping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function byDay(dir: SortDirection) {
|
||||||
|
return (a: TimelineItem, b: TimelineItem) => {
|
||||||
|
const da = parseDate(a).day
|
||||||
|
const db = parseDate(b).day
|
||||||
|
if (da == null && db == null) return a.title.localeCompare(b.title)
|
||||||
|
if (da == null) return 1
|
||||||
|
if (db == null) return -1
|
||||||
|
return dir === 'desc' ? db - da : da - db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byFeaturedThenDay(dir: SortDirection) {
|
||||||
|
const day = byDay(dir)
|
||||||
|
return (a: TimelineItem, b: TimelineItem) => {
|
||||||
|
if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1
|
||||||
|
return day(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupOptions = {
|
||||||
|
direction?: SortDirection
|
||||||
|
/**
|
||||||
|
* Decades ending before this year get the pre-program treatment even
|
||||||
|
* if the decade row doesn't say so. Lets the gap survive missing
|
||||||
|
* metadata.
|
||||||
|
*/
|
||||||
|
programStartYear?: number
|
||||||
|
/**
|
||||||
|
* Repeat featured items inside their month node as well as in the
|
||||||
|
* featured block. Off by default — in a sparse year it just prints the
|
||||||
|
* same line twice. A month left with nothing but featured items drops
|
||||||
|
* out entirely.
|
||||||
|
*/
|
||||||
|
featuredInMonths?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket a flat item list into years. Shared by the main rail and the
|
||||||
|
* upcoming block above it. */
|
||||||
|
export function groupYears(
|
||||||
|
items: TimelineItem[],
|
||||||
|
options: GroupOptions = {},
|
||||||
|
): GroupedYear[] {
|
||||||
|
const direction = options.direction ?? 'desc'
|
||||||
|
const featuredInMonths = options.featuredInMonths ?? false
|
||||||
|
const sign = direction === 'desc' ? -1 : 1
|
||||||
|
|
||||||
|
const yearBuckets = new Map<number, TimelineItem[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const { year } = parseDate(item)
|
||||||
|
const bucket = yearBuckets.get(year)
|
||||||
|
if (bucket) bucket.push(item)
|
||||||
|
else yearBuckets.set(year, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const years: GroupedYear[] = []
|
||||||
|
|
||||||
|
for (const [year, yearItems] of yearBuckets) {
|
||||||
|
const featuredItems: TimelineItem[] = []
|
||||||
|
const undated: TimelineItem[] = []
|
||||||
|
const monthMap = new Map<number, TimelineItem[]>()
|
||||||
|
|
||||||
|
for (const item of yearItems) {
|
||||||
|
if (item.featured) {
|
||||||
|
featuredItems.push(item)
|
||||||
|
if (!featuredInMonths) continue
|
||||||
|
}
|
||||||
|
const { month } = parseDate(item)
|
||||||
|
if (month == null) {
|
||||||
|
undated.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const bucket = monthMap.get(month)
|
||||||
|
if (bucket) bucket.push(item)
|
||||||
|
else monthMap.set(month, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const months: GroupedMonth[] = [...monthMap.entries()]
|
||||||
|
.sort((a, b) => sign * (a[0] - b[0]))
|
||||||
|
.map(([month, monthItems]) => ({
|
||||||
|
month,
|
||||||
|
label: MONTH_LABELS[month - 1] ?? `Month ${month}`,
|
||||||
|
items: monthItems.sort(byFeaturedThenDay(direction)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
featuredItems.sort(byDay(direction))
|
||||||
|
undated.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
|
||||||
|
years.push({
|
||||||
|
year,
|
||||||
|
featured: featuredItems.length > 0,
|
||||||
|
count: yearItems.length,
|
||||||
|
featuredItems,
|
||||||
|
undated,
|
||||||
|
months,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return years.sort((a, b) => sign * (a.year - b.year))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTimeline(
|
||||||
|
items: TimelineItem[],
|
||||||
|
decades: DecadeMeta[],
|
||||||
|
options: GroupOptions = {},
|
||||||
|
): GroupedDecade[] {
|
||||||
|
const direction = options.direction ?? 'desc'
|
||||||
|
const sign = direction === 'desc' ? -1 : 1
|
||||||
|
const metaByDecade = new Map(decades.map((d) => [d.decade, d]))
|
||||||
|
|
||||||
|
const decadeBuckets = new Map<number, GroupedYear[]>()
|
||||||
|
for (const year of groupYears(items, options)) {
|
||||||
|
const dec = decadeOf(year.year)
|
||||||
|
const bucket = decadeBuckets.get(dec)
|
||||||
|
if (bucket) bucket.push(year)
|
||||||
|
else decadeBuckets.set(dec, [year])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include decades that have metadata but no items yet, so an authored
|
||||||
|
// "before NGU" decade still renders its marker.
|
||||||
|
for (const meta of decades) {
|
||||||
|
if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...decadeBuckets.entries()]
|
||||||
|
.sort((a, b) => sign * (a[0] - b[0]))
|
||||||
|
.map(([decade, years]) => {
|
||||||
|
const meta = metaByDecade.get(decade)
|
||||||
|
const inferredPreProgram =
|
||||||
|
options.programStartYear != null && decade + 9 < options.programStartYear
|
||||||
|
return {
|
||||||
|
decade,
|
||||||
|
title: meta?.title ?? decadeLabel(decade),
|
||||||
|
tagline: meta?.tagline ?? '',
|
||||||
|
blurb: meta?.blurb,
|
||||||
|
preProgram: meta?.preProgram ?? inferredPreProgram,
|
||||||
|
years: years.sort((a, b) => sign * (a.year - b.year)),
|
||||||
|
count: years.reduce((sum, y) => sum + y.count, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert empty year nodes between the first and last year that actually
|
||||||
|
* has data, so sparse decades read as gaps in the record rather than as
|
||||||
|
* a shorter decade. Does not pad beyond the data.
|
||||||
|
*/
|
||||||
|
export function withGapYears(
|
||||||
|
years: GroupedYear[],
|
||||||
|
direction: SortDirection = 'desc',
|
||||||
|
): GroupedYear[] {
|
||||||
|
if (years.length < 2) return years
|
||||||
|
|
||||||
|
const present = new Map(years.map((y) => [y.year, y]))
|
||||||
|
const all = years.map((y) => y.year)
|
||||||
|
const min = Math.min(...all)
|
||||||
|
const max = Math.max(...all)
|
||||||
|
const filled: GroupedYear[] = []
|
||||||
|
|
||||||
|
for (let year = min; year <= max; year += 1) {
|
||||||
|
filled.push(
|
||||||
|
present.get(year) ?? {
|
||||||
|
year,
|
||||||
|
featured: false,
|
||||||
|
count: 0,
|
||||||
|
featuredItems: [],
|
||||||
|
undated: [],
|
||||||
|
months: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'desc' ? filled.reverse() : filled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Years that should start expanded: the most recent year with featured items. */
|
||||||
|
export function defaultOpenYears(decades: GroupedDecade[]): number[] {
|
||||||
|
for (const decade of decades) {
|
||||||
|
if (decade.preProgram) continue
|
||||||
|
const hit = decade.years.find((y) => y.featured)
|
||||||
|
if (hit) return [hit.year]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
75
src/lib/timelineRefs.ts
Normal file
75
src/lib/timelineRefs.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TIMELINE REFS
|
||||||
|
|
||||||
|
Turns a timeline item into a destination and an image. Both were
|
||||||
|
answered locally here before, which is how the timeline came to
|
||||||
|
link events at /event/:id while the router only knew about
|
||||||
|
/retreats/:id — two files holding the same opinion, one of them
|
||||||
|
wrong, neither aware of the other.
|
||||||
|
|
||||||
|
Now this file knows about timeline items and nothing else. Where
|
||||||
|
a record lives is hrefs.ts; where an image lives is media.ts.
|
||||||
|
|
||||||
|
── The shape this reads, from history.js ──
|
||||||
|
|
||||||
|
item.href explicit link_url override, may be off-site
|
||||||
|
item.ref { kind, id, orgKind? } — orgKind only on
|
||||||
|
organizations, because only they have it
|
||||||
|
item.logo { file, kind } — v_timeline COALESCEs the
|
||||||
|
filename across five tables, so the kind is
|
||||||
|
what says which directory it came from
|
||||||
|
item.team { id, name, orgId? } on a team ref
|
||||||
|
item.people[] { id, name, photo?, title? }
|
||||||
|
|
||||||
|
Every one of those is optional. An entry is a standalone
|
||||||
|
milestone until proven otherwise, and the two accessors below
|
||||||
|
return null rather than assuming a shape that isn't there —
|
||||||
|
which is the other half of the /organizations/undefined bug:
|
||||||
|
reaching into a ref that wasn't sent yields undefined, and
|
||||||
|
undefined interpolates into a path perfectly happily.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { refHref } from './hrefs.ts'
|
||||||
|
import { logoForKind, personPhoto } from './media.ts'
|
||||||
|
import type { TimelineItem } from './timeline.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this entry points, or null if nowhere.
|
||||||
|
*
|
||||||
|
* An explicit link_url wins: it's the editor deliberately
|
||||||
|
* overriding the record's own page, usually to send someone to an
|
||||||
|
* external write-up. TimelineEntry checks for a scheme and renders
|
||||||
|
* an <a> instead of a <Link>, so this returns it unchanged.
|
||||||
|
*
|
||||||
|
* Otherwise the reference decides, and refHref returns null for a
|
||||||
|
* kind with no page yet ('person', until /people/:id exists) as
|
||||||
|
* well as for a ref that didn't resolve. Null means the entry
|
||||||
|
* renders as a plain <div> — right for a milestone, and right for
|
||||||
|
* a reference that's missing an id, which used to render as a link
|
||||||
|
* to a 404.
|
||||||
|
*/
|
||||||
|
export function hrefFor(item: TimelineItem): string | null {
|
||||||
|
if (item.href) return item.href
|
||||||
|
|
||||||
|
const ref = item.ref
|
||||||
|
if (!ref) return null
|
||||||
|
|
||||||
|
return refHref(ref.kind, ref.id, ref.orgKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry's image, resolved against the directory for whatever
|
||||||
|
* kind of record the filename came from.
|
||||||
|
*/
|
||||||
|
export function logoSrc(item: TimelineItem): string | null {
|
||||||
|
if (!item.logo) return null
|
||||||
|
return logoForKind(item.logo.kind, item.logo.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A roster member's photo. Same rule as everywhere else — the API
|
||||||
|
* sends a bare filename.
|
||||||
|
*/
|
||||||
|
export function photoSrc(photo?: string | null): string | null {
|
||||||
|
return personPhoto(photo)
|
||||||
|
}
|
||||||
338
src/lib/useContent.ts
Normal file
338
src/lib/useContent.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
/**
|
||||||
|
* The four detail endpoints, typed.
|
||||||
|
*
|
||||||
|
* Adding a fifth is a type and a one-line hook — useRecord owns
|
||||||
|
* the fetching, the caching and the 404.
|
||||||
|
*
|
||||||
|
* An id that is missing — or the literal string "undefined", which
|
||||||
|
* is what a template literal makes of a missing route param — never
|
||||||
|
* reaches the network. detailPath returns null and the hook reports
|
||||||
|
* notFound, which is what the visitor should see anyway, and
|
||||||
|
* hrefs.ts has already logged the component that produced it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detailPath } from './hrefs.ts'
|
||||||
|
import { useRecord, type Resource } from './useRecord.ts'
|
||||||
|
import type { EventType } from './eventTypes.ts'
|
||||||
|
import type { EventSeries } from './eventSeries.ts'
|
||||||
|
|
||||||
|
/* ── Shared shapes ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** A content_blocks row with its `items` child, as shape.js sends it. */
|
||||||
|
export type ContentBlock = {
|
||||||
|
id?: number | string
|
||||||
|
slot?: 'card' | 'body'
|
||||||
|
type:
|
||||||
|
| 'heading'
|
||||||
|
| 'subheading'
|
||||||
|
| 'paragraph'
|
||||||
|
| 'list'
|
||||||
|
| 'links'
|
||||||
|
| 'quote'
|
||||||
|
| 'image'
|
||||||
|
| 'divider'
|
||||||
|
text?: string | null
|
||||||
|
media?: string | null
|
||||||
|
href?: string | null
|
||||||
|
items?: Array<{ text: string; detail?: string | null; url?: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
kind?: string
|
||||||
|
platform?: string | null
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
is_primary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgRef = { id: string; name: string; kind?: string | null }
|
||||||
|
|
||||||
|
/* One host of an event. `kind` says which table the id is in;
|
||||||
|
`org_kind` is the region/chapter/partner split that decides an
|
||||||
|
organization's route, and is null for a person. The triple is
|
||||||
|
exactly what refHref() in hrefs.ts takes. */
|
||||||
|
export type EventHost = {
|
||||||
|
kind: 'organization' | 'person'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
org_kind?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Events ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type EventPerson = {
|
||||||
|
person_id: string
|
||||||
|
display_name: string
|
||||||
|
pronouns?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
photo?: string | null
|
||||||
|
role?: string | null
|
||||||
|
title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventAward = {
|
||||||
|
award: { id: string; name: string; logo?: string | null }
|
||||||
|
person: { id: string; name: string; photo?: string | null }
|
||||||
|
awarded_on?: string | null
|
||||||
|
citation?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventRecord = {
|
||||||
|
id: string
|
||||||
|
/** Whose gathering it is: an event_scopes id. */
|
||||||
|
scope_id: string
|
||||||
|
/** What kind of gathering it is. Orthogonal to scope_id. */
|
||||||
|
event_type: EventType
|
||||||
|
title: string
|
||||||
|
theme?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
starts_on?: string | null
|
||||||
|
ends_on?: string | null
|
||||||
|
date_label?: string | null
|
||||||
|
status: 'upcoming' | 'past' | 'cancelled'
|
||||||
|
/** The repeating schedule, or null for a one-off. */
|
||||||
|
series: EventSeries | null
|
||||||
|
location_label?: string | null
|
||||||
|
locality?: string | null
|
||||||
|
state_code?: string | null
|
||||||
|
country?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
org_logo?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
color?: string | null
|
||||||
|
gradient?: string | null
|
||||||
|
/* In billing order. The first is the one `color` and `org_logo`
|
||||||
|
fell back to when the event set neither. */
|
||||||
|
hosts: EventHost[]
|
||||||
|
description: string[]
|
||||||
|
links: Link[]
|
||||||
|
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
||||||
|
instagram?: string | null
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
people: EventPerson[]
|
||||||
|
awards: EventAward[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /events: shapeEvent without the detail-only
|
||||||
|
* blocks, people and awards. */
|
||||||
|
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
|
||||||
|
|
||||||
|
/** An event_scopes row, as GET /events sends it beside the list. */
|
||||||
|
export type EventScope = { id: string; name: string; sort_order: number }
|
||||||
|
|
||||||
|
export type EventsResponse = { scopes: EventScope[]; events: EventListItem[] }
|
||||||
|
|
||||||
|
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||||
|
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||||
|
|
||||||
|
/* ── Organizations ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Leader = {
|
||||||
|
person_id: string
|
||||||
|
display_name: string
|
||||||
|
pronouns?: string | null
|
||||||
|
title?: string | null
|
||||||
|
role?: string | null
|
||||||
|
is_owner: boolean
|
||||||
|
photo?: string | null
|
||||||
|
public_email?: string | null
|
||||||
|
team_id?: string | null
|
||||||
|
team_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgTeam = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgAward = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
recipient_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgEvent = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
event_type: EventType
|
||||||
|
date_label?: string | null
|
||||||
|
status: string
|
||||||
|
location_label?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
color?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** regions.scope's CHECK values. */
|
||||||
|
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
||||||
|
|
||||||
|
/** A region_areas row as attachRegionDetails sends it. */
|
||||||
|
export type RegionArea = {
|
||||||
|
area_code: string
|
||||||
|
share: number
|
||||||
|
edge: 'top' | 'bottom' | null
|
||||||
|
note: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganizationRecord = {
|
||||||
|
id: string
|
||||||
|
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
name: string
|
||||||
|
short_name?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
venue?: string | null
|
||||||
|
address?: string | null
|
||||||
|
locality?: string | null
|
||||||
|
state_code?: string | null
|
||||||
|
country?: string | null
|
||||||
|
location_label?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
description: string[]
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
links: Link[]
|
||||||
|
socials: Link[]
|
||||||
|
/* splitLinks in shape.js lifts these out as bare strings: the
|
||||||
|
website's url, the email's label, the instagram handle. */
|
||||||
|
website?: string | null
|
||||||
|
email?: string | null
|
||||||
|
instagram?: string | null
|
||||||
|
/** Shape depends on `kind`; empty object for national and partner. */
|
||||||
|
details: {
|
||||||
|
scope?: RegionScope | null
|
||||||
|
map_note?: string | null
|
||||||
|
areas?: RegionArea[]
|
||||||
|
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||||
|
region_id?: string | null
|
||||||
|
region_name?: string | null
|
||||||
|
region_color?: string | null
|
||||||
|
meets?: string | null
|
||||||
|
started?: string | null
|
||||||
|
}
|
||||||
|
leadership: Leader[]
|
||||||
|
teams: OrgTeam[]
|
||||||
|
awards: OrgAward[]
|
||||||
|
events: OrgEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of GET /organizations: the card surface and details,
|
||||||
|
* without the sections only the org's own page loads. */
|
||||||
|
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
||||||
|
|
||||||
|
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||||
|
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||||
|
|
||||||
|
/* ── Teams ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** No members here on purpose — PeopleTiles fetches
|
||||||
|
* /teams/:id/people itself. See the note in content.js. */
|
||||||
|
export type TeamRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tagline?: string | null
|
||||||
|
color?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
org: OrgRef
|
||||||
|
description: string[]
|
||||||
|
links: Link[]
|
||||||
|
socials: Link[]
|
||||||
|
/** The instagram handle. See splitLinks in shape.js. */
|
||||||
|
instagram?: string | null
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTeam = (id?: string): Resource<TeamRecord> =>
|
||||||
|
useRecord<TeamRecord>(detailPath('/teams', id), 'team')
|
||||||
|
|
||||||
|
/* ── Awards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Recipient = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
awarded_on?: string | null
|
||||||
|
citation?: string | null
|
||||||
|
event: { id: string; title: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AwardRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
org: OrgRef | null
|
||||||
|
recipients: Recipient[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAward = (id?: string): Resource<AwardRecord> =>
|
||||||
|
useRecord<AwardRecord>(detailPath('/awards', id), 'award')
|
||||||
|
|
||||||
|
/* ── People ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** One public affiliation. ended_on null means current. */
|
||||||
|
export type PersonRole = {
|
||||||
|
title?: string | null
|
||||||
|
role: 'lead' | 'board' | 'staff' | 'volunteer' | 'member'
|
||||||
|
is_owner: boolean
|
||||||
|
started_on?: string | null
|
||||||
|
ended_on?: string | null
|
||||||
|
org: OrgRef
|
||||||
|
/** Null when there's no team, or the team is unpublished. */
|
||||||
|
team: { id: string; name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A published event this person was billed at or hosted, with
|
||||||
|
* every capacity they appeared in. 'host' comes from event_hosts. */
|
||||||
|
export type PersonEvent = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
event_type: EventType
|
||||||
|
date_label?: string | null
|
||||||
|
starts_on?: string | null
|
||||||
|
status: 'upcoming' | 'past' | 'cancelled'
|
||||||
|
roles: { role: string; title?: string | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonAward = {
|
||||||
|
award: { id: string; name: string; logo?: string | null }
|
||||||
|
awarded_on?: string | null
|
||||||
|
citation?: string | null
|
||||||
|
event: { id: string; title: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
pronouns?: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
photo?: string | null
|
||||||
|
location_label?: string | null
|
||||||
|
public_email?: string | null
|
||||||
|
/** The primary organization, when it's published. */
|
||||||
|
org: OrgRef | null
|
||||||
|
/** people.bio split on blank lines. */
|
||||||
|
bio: string[]
|
||||||
|
description: string[]
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
links: Link[]
|
||||||
|
socials: Link[]
|
||||||
|
website?: string | null
|
||||||
|
instagram?: string | null
|
||||||
|
/** Current first, then most recently ended. */
|
||||||
|
roles: PersonRole[]
|
||||||
|
events: PersonEvent[]
|
||||||
|
awards: PersonAward[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePerson = (id?: string): Resource<PersonRecord> =>
|
||||||
|
useRecord<PersonRecord>(detailPath('/people', id), 'person')
|
||||||
95
src/lib/useFrontPage.ts
Normal file
95
src/lib/useFrontPage.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FRONT PAGE DATA
|
||||||
|
|
||||||
|
GET /front-page, as routes/home.js shapes it: the hero, the
|
||||||
|
visible sections in order, counted stats, paths with actions,
|
||||||
|
and the countdown's event. Edited in the admin under Front page.
|
||||||
|
|
||||||
|
No fallback content. If the request fails the page says so — a
|
||||||
|
plausible default home page would hide a broken server behind
|
||||||
|
something that looks fine.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useRecord, type Resource } from './useRecord.ts'
|
||||||
|
import type { EventSeries } from './eventSeries.ts'
|
||||||
|
|
||||||
|
export type HeroMode = 'brand' | 'photos' | 'livestream'
|
||||||
|
|
||||||
|
/** The CHECK list on front_page_sections.section. */
|
||||||
|
export type FrontPageSectionKey =
|
||||||
|
| 'countdown'
|
||||||
|
| 'retreats'
|
||||||
|
| 'calendar'
|
||||||
|
| 'stats'
|
||||||
|
| 'timeline'
|
||||||
|
| 'connect'
|
||||||
|
|
||||||
|
export type HeroButton = { label: string; url: string }
|
||||||
|
|
||||||
|
export type HeroSlide = {
|
||||||
|
media: string
|
||||||
|
alt?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
link_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Hero = {
|
||||||
|
mode: HeroMode
|
||||||
|
eyebrow?: string | null
|
||||||
|
headline: string
|
||||||
|
subhead?: string | null
|
||||||
|
primary: HeroButton | null
|
||||||
|
secondary: HeroButton | null
|
||||||
|
slide_seconds: number
|
||||||
|
slides: HeroSlide[]
|
||||||
|
livestream: { url: string; title?: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPageSection = {
|
||||||
|
section: FrontPageSectionKey
|
||||||
|
/** Null → the section's own heading. */
|
||||||
|
title?: string | null
|
||||||
|
blurb?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPageStat = {
|
||||||
|
label: string
|
||||||
|
/** Already counted or typed; never null — the API drops those. */
|
||||||
|
value: string
|
||||||
|
suffix?: string | null
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PathAction = { label: string; description?: string | null; url: string }
|
||||||
|
|
||||||
|
export type FrontPagePath = {
|
||||||
|
label: string
|
||||||
|
icon?: string | null
|
||||||
|
blurb?: string | null
|
||||||
|
actions: PathAction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountdownEvent = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
theme?: string | null
|
||||||
|
starts_on?: string | null
|
||||||
|
ends_on?: string | null
|
||||||
|
date_label?: string | null
|
||||||
|
location_label?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
color?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
series: EventSeries | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPage = {
|
||||||
|
hero: Hero
|
||||||
|
sections: FrontPageSection[]
|
||||||
|
stats: FrontPageStat[]
|
||||||
|
paths: FrontPagePath[]
|
||||||
|
countdown: CountdownEvent | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFrontPage = (): Resource<FrontPage> =>
|
||||||
|
useRecord<FrontPage>('/front-page', 'front_page')
|
||||||
87
src/lib/useHistory.ts
Normal file
87
src/lib/useHistory.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Loads the history timeline from `GET /api/history`.
|
||||||
|
*
|
||||||
|
* Goes through the shared client, so the request is deduped and cached
|
||||||
|
* for 60s like every other read. Two consequences worth knowing:
|
||||||
|
*
|
||||||
|
* · `reload` has to invalidate before it refetches. Without that, the
|
||||||
|
* retry button inside the TTL would hand back the same settled
|
||||||
|
* promise and look like it did nothing. A *failed* request is
|
||||||
|
* already evicted by the client, so this matters for the refresh
|
||||||
|
* case rather than the error case.
|
||||||
|
*
|
||||||
|
* · there's no AbortController. `get` shares one promise between
|
||||||
|
* callers, so aborting on unmount would cancel someone else's
|
||||||
|
* request. The `live` flag drops the result instead.
|
||||||
|
*
|
||||||
|
* No `fallback` on purpose. Handing this the mock data would render a
|
||||||
|
* plausible-looking history with no indication the server is down, and
|
||||||
|
* the wrong history is worse than a visible error — the same reason
|
||||||
|
* `fallback: EMPTY` came out elsewhere.
|
||||||
|
*
|
||||||
|
* `undated` is the number of published entries the API left out because
|
||||||
|
* nothing gave them a date. Not rendered publicly — a visitor can't act
|
||||||
|
* on it — but returned so it's reachable if you want a warning in the
|
||||||
|
* admin later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { get, invalidate, ApiError } from './api.ts'
|
||||||
|
import type { TimelineItem } from './timeline'
|
||||||
|
|
||||||
|
const PATH = '/history'
|
||||||
|
|
||||||
|
type HistoryResponse = {
|
||||||
|
items?: TimelineItem[]
|
||||||
|
undated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
items: TimelineItem[]
|
||||||
|
undated: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistory(): State {
|
||||||
|
const [items, setItems] = useState<TimelineItem[]>([])
|
||||||
|
const [undated, setUndated] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
invalidate(PATH)
|
||||||
|
setAttempt((n) => n + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data: HistoryResponse = await get(PATH)
|
||||||
|
if (!live) return
|
||||||
|
setItems(Array.isArray(data?.items) ? data.items : [])
|
||||||
|
setUndated(Number(data?.undated) || 0)
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [attempt])
|
||||||
|
|
||||||
|
return { items, undated, loading, error, reload }
|
||||||
|
}
|
||||||
127
src/lib/useRecord.ts
Normal file
127
src/lib/useRecord.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* useRecord — one record from one endpoint, on the useHistory
|
||||||
|
* pattern.
|
||||||
|
*
|
||||||
|
* Named for what it returns, and deliberately not useResource:
|
||||||
|
* src/lib/useResource.ts is a different hook — it hands back the
|
||||||
|
* whole response body and takes an options object — and a .ts file
|
||||||
|
* of the same name would sit one extension away from it. An import
|
||||||
|
* whose target goes missing then resolves to the other file without
|
||||||
|
* a word, and every detail page renders the wrapper object instead
|
||||||
|
* of the record. That is exactly how this file came to exist.
|
||||||
|
*
|
||||||
|
* Every detail route in content.js answers the same shape: 200 with
|
||||||
|
* a single top-level key, or 404 with `{ error }`. So the hook takes
|
||||||
|
* the path and the key, and the four callers in useContent.ts are
|
||||||
|
* one line each rather than four copies of this file.
|
||||||
|
*
|
||||||
|
* `key` is a string rather than a selector function on purpose. A
|
||||||
|
* selector passed inline would be a new identity every render, and
|
||||||
|
* putting it in the effect's deps would refetch forever; leaving it
|
||||||
|
* out would silently use a stale closure. A string has neither
|
||||||
|
* problem.
|
||||||
|
*
|
||||||
|
* Carried over from useHistory, and worth restating:
|
||||||
|
*
|
||||||
|
* · `reload` invalidates before it refetches. Without that, the
|
||||||
|
* retry button inside the 60s TTL hands back the same settled
|
||||||
|
* promise and looks like it did nothing. A *failed* request is
|
||||||
|
* already evicted by api.ts, so this matters for the refresh
|
||||||
|
* case rather than the error case.
|
||||||
|
*
|
||||||
|
* · no AbortController. `get` shares one promise between callers,
|
||||||
|
* so aborting on unmount would cancel someone else's request.
|
||||||
|
* The `live` flag drops the result instead.
|
||||||
|
*
|
||||||
|
* · no `fallback`. Rendering plausible-looking content with no
|
||||||
|
* sign the server is down is worse than a visible error.
|
||||||
|
*
|
||||||
|
* `notFound` is separated from `error` because they are different
|
||||||
|
* pages: a 404 is a slug that doesn't exist and retrying won't help,
|
||||||
|
* anything else is worth a Try again button.
|
||||||
|
*
|
||||||
|
* A null `path` means there is nothing to ask for, and the hook
|
||||||
|
* reports notFound rather than loading forever. Callers build the
|
||||||
|
* path with detailPath() from hrefs.ts, which returns null for an
|
||||||
|
* id that could never be real — "undefined" chief among them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { get, invalidate, ApiError } from './api.ts'
|
||||||
|
|
||||||
|
export type Resource<T> = {
|
||||||
|
data: T | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
notFound: boolean
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
if (path) invalidate(path)
|
||||||
|
setAttempt((n) => n + 1)
|
||||||
|
}, [path])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Nothing to ask for. Distinguished from "not asked yet" by the
|
||||||
|
// caller: useContent passes null only when the id is unusable,
|
||||||
|
// and an unusable id is a 404 as far as the visitor is
|
||||||
|
// concerned.
|
||||||
|
if (!path) {
|
||||||
|
setData(null)
|
||||||
|
setError(null)
|
||||||
|
setNotFound(true)
|
||||||
|
setLoading(false)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setNotFound(false)
|
||||||
|
try {
|
||||||
|
const body = await get(path as string)
|
||||||
|
if (!live) return
|
||||||
|
// A 200 with the key absent is a server-side shaping bug,
|
||||||
|
// not an empty record. Say so rather than rendering a page
|
||||||
|
// full of blanks.
|
||||||
|
const record = body?.[key]
|
||||||
|
if (record === undefined) {
|
||||||
|
setError(`The server sent no "${key}".`)
|
||||||
|
setData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData(record as T)
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
setNotFound(true)
|
||||||
|
setData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||||
|
)
|
||||||
|
setData(null)
|
||||||
|
} finally {
|
||||||
|
if (live) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [path, key, attempt])
|
||||||
|
|
||||||
|
return { data, loading, error, notFound, reload }
|
||||||
|
}
|
||||||
|
|
@ -18,13 +18,13 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get } from "./api.js";
|
import { get } from "./api.ts";
|
||||||
|
|
||||||
export function useResource(path, { ttl, fallback } = {}) {
|
export function useResource(path, { ttl, fallback }: { ttl?: number; fallback?: any } = {}) {
|
||||||
// Seed with the fallback so the first paint has content when one
|
// Seed with the fallback so the first paint has content when one
|
||||||
// is available, rather than flashing a spinner and then the same
|
// is available, rather than flashing a spinner and then the same
|
||||||
// data a moment later.
|
// data a moment later.
|
||||||
const [state, setState] = useState(() => ({
|
const [state, setState] = useState<any>(() => ({
|
||||||
data: fallback,
|
data: fallback,
|
||||||
error: null,
|
error: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
10
src/lib/version.ts
Normal file
10
src/lib/version.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
SITE VERSION — src/lib/version.ts
|
||||||
|
|
||||||
|
One string, bumped by hand when the working directory name
|
||||||
|
changes. The admin footer is the only thing reading it today;
|
||||||
|
keep it here rather than in a component so the panel, an about
|
||||||
|
box or a build banner can read the same value later.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export const SITE_VERSION = "NGU-Web.v1.5-history";
|
||||||
|
|
@ -7,15 +7,18 @@ export const PAGE_LINKS = [
|
||||||
{ label: "Community", path: "/community" },
|
{ label: "Community", path: "/community" },
|
||||||
{ label: "Leadership", path: "/leadership" },
|
{ label: "Leadership", path: "/leadership" },
|
||||||
{ label: "Resources", path: "/resources" },
|
{ label: "Resources", path: "/resources" },
|
||||||
|
{ label: "History", path: "/history" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sections belong to a page, keyed by that page's path.
|
// Sections belong to a page, keyed by that page's path.
|
||||||
// A page with no sections gets no subnav row.
|
// A page with no sections gets no subnav row.
|
||||||
export const PAGE_SECTIONS = {
|
export const PAGE_SECTIONS = {
|
||||||
"/": [
|
"/": [
|
||||||
{ label: "#About", hash: "#about" },
|
{ label: "#Featured", hash: "#hero" },
|
||||||
{ label: "#Events", hash: "#events" },
|
|
||||||
{ label: "#Connect", hash: "#connect" },
|
{ label: "#Connect", hash: "#connect" },
|
||||||
|
{ label: "#Retreats", hash: "#retreats" },
|
||||||
|
{ label: "#Calendar", hash: "#calendar" },
|
||||||
|
{ label: "#About", hash: "#numbers" },
|
||||||
],
|
],
|
||||||
"/retreats": [
|
"/retreats": [
|
||||||
{ label: "National", hash: "#national" },
|
{ label: "National", hash: "#national" },
|
||||||
|
|
@ -38,6 +41,9 @@ export const PAGE_SECTIONS = {
|
||||||
{ label: "Branding & Marketing", hash: "#branding" },
|
{ label: "Branding & Marketing", hash: "#branding" },
|
||||||
{ label: "Partner Resources", hash: "#partner" },
|
{ label: "Partner Resources", hash: "#partner" },
|
||||||
],
|
],
|
||||||
|
"/history": [
|
||||||
|
{ label: "Timeline", hash: "#timeline" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Right-side actions. `variant` picks the styling, not the destination.
|
// Right-side actions. `variant` picks the styling, not the destination.
|
||||||
178
src/pages/AwardDetail.tsx
Normal file
178
src/pages/AwardDetail.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
AWARD DETAIL — /awards/:id
|
||||||
|
|
||||||
|
The thinnest of the four, by design. `awards` has no links and no
|
||||||
|
content blocks — 'award' isn't in the owner_kind CHECK on either
|
||||||
|
polymorphic table, and widening it means rebuilding two STRICT
|
||||||
|
tables. `description` is the prose.
|
||||||
|
|
||||||
|
So the recipients are the page. They're rendered as a list rather
|
||||||
|
than as PeopleTiles because each one carries a citation, and a
|
||||||
|
citation is the point of an award — it doesn't belong folded
|
||||||
|
behind a chevron.
|
||||||
|
|
||||||
|
Note also that `awards` has no is_published column: every row
|
||||||
|
here is live the moment it's created. Worth an ADD COLUMN before
|
||||||
|
this ships.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import { useAward, type Recipient } from '../lib/useContent.ts'
|
||||||
|
import { eventHref, orgHref, personHref } from '../lib/hrefs.ts'
|
||||||
|
import { awardLogo, initials, personPhoto } from '../lib/media.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function AwardDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: award, loading, error, notFound, reload } = useAward(id)
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="award"
|
||||||
|
backTo="/community"
|
||||||
|
backLabel="Community"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = TEAL
|
||||||
|
const logo = awardLogo(award.logo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={award.name}
|
||||||
|
intro={award.description ?? undefined}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: 'recipients',
|
||||||
|
title: 'Recipients',
|
||||||
|
blurb:
|
||||||
|
award.recipients.length === 0
|
||||||
|
? 'Nobody has received this yet.'
|
||||||
|
: undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
{logo && (
|
||||||
|
<img src={logo} alt="" loading="lazy" className="h-12 w-12 object-contain" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{award.org && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Given by{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(award.org.id, award.org.kind)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{award.org.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
{award.recipients.length}{' '}
|
||||||
|
{award.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-8">
|
||||||
|
{award.recipients.map((recipient, index) => (
|
||||||
|
<li key={`${recipient.id}:${recipient.awarded_on ?? index}`}>
|
||||||
|
<RecipientRow recipient={recipient} accent={accent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipientRow({ recipient, accent }: { recipient: Recipient; accent: string }) {
|
||||||
|
const photo = personPhoto(recipient.photo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{photo ? (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{initials(recipient.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
<Link
|
||||||
|
to={personHref(recipient.id)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{recipient.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{[
|
||||||
|
year(recipient.awarded_on),
|
||||||
|
recipient.tagline,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
{recipient.event && (
|
||||||
|
<>
|
||||||
|
{(recipient.awarded_on || recipient.tagline) && ' · '}
|
||||||
|
<Link
|
||||||
|
to={eventHref(recipient.event.id)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{recipient.event.title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{recipient.citation && (
|
||||||
|
<p className="mt-2 max-w-2xl leading-relaxed" style={{ color: BODY }}>
|
||||||
|
{recipient.citation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* awarded_on may be a partial date, so this takes the leading year
|
||||||
|
rather than parsing. A full date would be false precision on a
|
||||||
|
row backfilled from a programme booklet. */
|
||||||
|
function year(date?: string | null): string | null {
|
||||||
|
if (!date) return null
|
||||||
|
const match = /^(\d{4})/.exec(date)
|
||||||
|
return match ? match[1] : date
|
||||||
|
}
|
||||||
399
src/pages/EventDetail.tsx
Normal file
399
src/pages/EventDetail.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT DETAIL — /retreats/:id
|
||||||
|
|
||||||
|
Reached from the cards on Retreats.tsx. The API has already
|
||||||
|
resolved the fallbacks — `color` is the event's own or its first
|
||||||
|
host's, `status` is derived from the dates when nobody set it —
|
||||||
|
so nothing here reimplements those rules.
|
||||||
|
|
||||||
|
Hosts arrive as a list in billing order, each one either an
|
||||||
|
organization or a person. refHref takes the kind and works out
|
||||||
|
the route, which is why this file doesn't branch on it.
|
||||||
|
|
||||||
|
People are grouped by their billing role rather than listed flat.
|
||||||
|
A retreat with four speakers and eleven volunteers reads as two
|
||||||
|
different things, and v_event_people already sorts within a role
|
||||||
|
by sort_order.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||||
|
import {
|
||||||
|
useEvent,
|
||||||
|
type EventHost,
|
||||||
|
type EventPerson,
|
||||||
|
type EventRecord,
|
||||||
|
} from '../lib/useContent.ts'
|
||||||
|
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
||||||
|
import { personPhoto } from '../lib/media.ts'
|
||||||
|
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||||
|
import { occurrenceLabel, seriesLabel, seriesTimes, upcomingOccurrences } from '../lib/eventSeries.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
/* Billing order. A role missing from here still renders, at the
|
||||||
|
end, under its own name — better than a speaker vanishing
|
||||||
|
because somebody added a role to the CHECK and not to this list. */
|
||||||
|
const ROLE_ORDER = [
|
||||||
|
'speaker',
|
||||||
|
'leader',
|
||||||
|
'facilitator',
|
||||||
|
'host',
|
||||||
|
'musician',
|
||||||
|
'volunteer',
|
||||||
|
'attendee',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
speaker: 'Speakers',
|
||||||
|
leader: 'Leaders',
|
||||||
|
facilitator: 'Facilitators',
|
||||||
|
host: 'Hosts',
|
||||||
|
musician: 'Music',
|
||||||
|
volunteer: 'Volunteers',
|
||||||
|
attendee: 'Also there',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
past: 'Past',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: event, loading, error, notFound, reload } = useEvent(id)
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="retreat"
|
||||||
|
backTo="/retreats"
|
||||||
|
backLabel="All retreats"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = event.color || TEAL
|
||||||
|
const groups = peopleGroups(event.people, accent)
|
||||||
|
|
||||||
|
const sections: ShellSection[] = [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: event.theme || 'About',
|
||||||
|
blurb: event.theme ? event.tagline ?? undefined : undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts event={event} accent={accent} />
|
||||||
|
|
||||||
|
{event.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={event.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.links.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{event.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// A cancelled series has no next meeting, whatever its dates say.
|
||||||
|
const upcoming =
|
||||||
|
event.status === 'cancelled'
|
||||||
|
? []
|
||||||
|
: upcomingOccurrences(event.series, event.starts_on, event.ends_on)
|
||||||
|
|
||||||
|
if (upcoming.length > 0) {
|
||||||
|
const times = seriesTimes(event.series)
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
id: 'dates',
|
||||||
|
title: 'Upcoming dates',
|
||||||
|
blurb: seriesLabel(event.series, event.starts_on) ?? undefined,
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{upcoming.map((date) => (
|
||||||
|
<li
|
||||||
|
key={date}
|
||||||
|
className="rounded-xl border-l-4 bg-white px-5 py-3"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
{occurrenceLabel(date)}
|
||||||
|
</p>
|
||||||
|
{times && (
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{times}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'people',
|
||||||
|
title: 'Who’s there',
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<PeopleTiles size="lg" groups={groups} accent={accent} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.awards.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'awards',
|
||||||
|
title: 'Presented here',
|
||||||
|
blurb: 'Awards given at this gathering.',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||||
|
{event.awards.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.award.id}:${entry.person.id}:${index}`}
|
||||||
|
className="flex gap-4 border-l-2 pl-5"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
<Link to={awardHref(entry.award.id)} className="hover:underline">
|
||||||
|
{entry.award.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p style={{ color: BODY }}>
|
||||||
|
<Link to={personHref(entry.person.id)} className="hover:underline">
|
||||||
|
{entry.person.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{entry.citation && (
|
||||||
|
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
|
||||||
|
{entry.citation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={event.title}
|
||||||
|
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The strip of facts under the heading ────────────────────── */
|
||||||
|
|
||||||
|
function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
||||||
|
const where =
|
||||||
|
event.location_label ||
|
||||||
|
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
||||||
|
(event.is_online ? 'Online' : null)
|
||||||
|
|
||||||
|
const schedule = seriesLabel(event.series, event.starts_on)
|
||||||
|
// A series with no label of its own is described by its schedule
|
||||||
|
// rather than by a start-to-end range that reads like one long
|
||||||
|
// gathering.
|
||||||
|
const when =
|
||||||
|
event.date_label || (schedule ? null : dateRange(event.starts_on, event.ends_on))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
<span
|
||||||
|
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||||
|
style={{
|
||||||
|
background: event.status === 'cancelled' ? '#b3261e' : accent,
|
||||||
|
color: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[event.status] ?? event.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Outlined rather than filled: the status pill is the one
|
||||||
|
thing in this strip that should read as loud, and two
|
||||||
|
solid blocks side by side would compete. Shown
|
||||||
|
unconditionally — a card suppresses its own type badge in
|
||||||
|
a band of one kind, but here there is no band to make it
|
||||||
|
redundant. */}
|
||||||
|
{event.event_type && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||||
|
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||||
|
>
|
||||||
|
{eventTypeLabel(event.event_type)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{when && <span style={{ color: BODY }}>{when}</span>}
|
||||||
|
{schedule && <span style={{ color: BODY }}>{schedule}</span>}
|
||||||
|
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||||
|
{event.is_online && where !== 'Online' && (
|
||||||
|
<span style={{ color: BODY }}>Online too</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(event.hosts?.length ?? 0) > 0 && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Hosted by <HostList hosts={event.hosts} accent={accent} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
|
||||||
|
All retreats
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One host reads as "Hosted by Northwest"; several read as a
|
||||||
|
sentence, so they're joined with commas and an "and" rather than
|
||||||
|
stacked. A host whose id didn't resolve to a route renders as
|
||||||
|
plain text — refHref returns null for that — because a dead link
|
||||||
|
is worse than a name. */
|
||||||
|
function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hosts.map((host, index) => {
|
||||||
|
const to = refHref(host.kind, host.id, host.org_kind)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={`${host.kind}:${host.id}`}>
|
||||||
|
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
|
||||||
|
{index > 0 && index === hosts.length - 1 && 'and '}
|
||||||
|
{to ? (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{host.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{host.name}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* date_label is what a card shows and is free text — "March/April
|
||||||
|
2026" is legitimate. This is only the fallback for an event that
|
||||||
|
has dates and no label. */
|
||||||
|
function dateRange(start?: string | null, end?: string | null): string | null {
|
||||||
|
if (!start) return null
|
||||||
|
const from = new Date(`${start}T00:00:00`)
|
||||||
|
if (Number.isNaN(from.getTime())) return start
|
||||||
|
|
||||||
|
const full: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!end || end === start) return from.toLocaleDateString(undefined, full)
|
||||||
|
|
||||||
|
const to = new Date(`${end}T00:00:00`)
|
||||||
|
if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full)
|
||||||
|
|
||||||
|
const sameYear = from.getFullYear() === to.getFullYear()
|
||||||
|
const sameMonth = sameYear && from.getMonth() === to.getMonth()
|
||||||
|
|
||||||
|
const left = from.toLocaleDateString(
|
||||||
|
undefined,
|
||||||
|
sameMonth
|
||||||
|
? { month: 'long', day: 'numeric' }
|
||||||
|
: sameYear
|
||||||
|
? { month: 'long', day: 'numeric' }
|
||||||
|
: full,
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${left} – ${to.toLocaleDateString(undefined, full)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── v_event_people → PeopleTiles groups ─────────────────────── */
|
||||||
|
|
||||||
|
function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] {
|
||||||
|
if (!people.length) return []
|
||||||
|
|
||||||
|
const byRole = new Map<string, EventPerson[]>()
|
||||||
|
for (const person of people) {
|
||||||
|
const role = person.role || 'attendee'
|
||||||
|
const list = byRole.get(role)
|
||||||
|
if (list) list.push(person)
|
||||||
|
else byRole.set(role, [person])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known roles in billing order, then anything the CHECK has
|
||||||
|
// gained since this file was written.
|
||||||
|
const roles = [
|
||||||
|
...ROLE_ORDER.filter((role) => byRole.has(role)),
|
||||||
|
...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)),
|
||||||
|
]
|
||||||
|
|
||||||
|
return roles.map((role) => ({
|
||||||
|
id: `role-${role}`,
|
||||||
|
label: ROLE_LABEL[role] ?? capitalize(role),
|
||||||
|
accent,
|
||||||
|
people: (byRole.get(role) ?? []).map((person) => ({
|
||||||
|
id: person.person_id,
|
||||||
|
name: person.display_name,
|
||||||
|
title: person.title,
|
||||||
|
tagline: person.tagline,
|
||||||
|
pronouns: person.pronouns,
|
||||||
|
// PeopleTiles resolves a bare filename itself; this is here
|
||||||
|
// so a hand-written entry elsewhere can't diverge.
|
||||||
|
photo: personPhoto(person.photo),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
84
src/pages/History.tsx
Normal file
84
src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import PageShell from "../components/PageShell";
|
||||||
|
import HistoryTimeline from "./sections/history/HistoryTimeline";
|
||||||
|
import { HISTORY_DECADES } from "../data/historyDecades";
|
||||||
|
import { useHistory } from "../lib/useHistory";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year the program starts. Decades ending before this render with the
|
||||||
|
* pre-program treatment, so the gap moves if the founding date is ever
|
||||||
|
* corrected — no hardcoded 2000 anywhere in the components.
|
||||||
|
*/
|
||||||
|
const PROGRAM_START_YEAR = 2000;
|
||||||
|
|
||||||
|
const ACCENT = "#138ba0";
|
||||||
|
const BACKGROUND = "#ffffff";
|
||||||
|
|
||||||
|
export default function History() {
|
||||||
|
const { items, loading, error, reload } = useHistory();
|
||||||
|
|
||||||
|
// Section renders `content` outside its own max-width wrapper, so the
|
||||||
|
// container lives here. The custom properties bind the timeline to
|
||||||
|
// this section's accent and background — --tl-surface must match
|
||||||
|
// `background` or the rail will show through the year dots.
|
||||||
|
const wrap = (children: React.ReactNode) => (
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-6"
|
||||||
|
style={{ "--tl-accent": ACCENT, "--tl-surface": BACKGROUND } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = wrap(
|
||||||
|
<p className="py-12 text-[#4a6b72]" role="status">
|
||||||
|
Loading the timeline…
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
// Say what failed and offer the one action that might fix it.
|
||||||
|
// A history page that silently renders nothing looks like an
|
||||||
|
// organization with no history.
|
||||||
|
content = wrap(
|
||||||
|
<div className="py-12">
|
||||||
|
<p className="text-[#b3261e]">Couldn’t load the timeline. {error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reload}
|
||||||
|
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = wrap(
|
||||||
|
<HistoryTimeline
|
||||||
|
items={items}
|
||||||
|
decades={HISTORY_DECADES}
|
||||||
|
direction="desc"
|
||||||
|
programStartYear={PROGRAM_START_YEAR}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="History"
|
||||||
|
intro="A near-complete timeline of Next Generation of Unity (NGU), and other Unity Young Adult programs."
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: "timeline",
|
||||||
|
title: "NGU Timeline",
|
||||||
|
blurb:
|
||||||
|
"Open a year to see what happened.",
|
||||||
|
accent: ACCENT,
|
||||||
|
background: BACKGROUND,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,477 +1,132 @@
|
||||||
import { useState, useEffect } from "react";
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
import nguLogo from "../assets/NGU_Logo.svg";
|
HOME — /
|
||||||
import fallLogo from "../assets/Fall Logo.svg";
|
|
||||||
import nguLogo_WhiteBG from "../assets/NGU_Logo_WhiteBG.svg";
|
|
||||||
|
|
||||||
{/* SVGs */}
|
Not a PageShell page: the front page has no title bar, and each
|
||||||
const DoveSVG = ({ className = "" }: { className?: string }) => (
|
band draws its own heading in its own style.
|
||||||
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs id="defs1" />
|
|
||||||
<g id="layer1" transform="translate(-70.490069,-117.83965)">
|
|
||||||
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
|
|
||||||
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
Driven by the admin's Front page editor through GET /front-page.
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
|
The hero comes first, always. After it, the bands in the order
|
||||||
<defs>
|
the admin dragged them into, minus any they hid. Which component
|
||||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
draws a band is decided here, in SECTIONS, keyed on the same list
|
||||||
<stop offset="0%" stopColor="#FEDA75" />
|
the CHECK on front_page_sections holds — the database says "retreats,
|
||||||
<stop offset="25%" stopColor="#FA7E1E" />
|
third, called National Retreats"; this file says what a retreats
|
||||||
<stop offset="50%" stopColor="#D62976" />
|
band looks like.
|
||||||
<stop offset="75%" stopColor="#962FBF" />
|
|
||||||
<stop offset="100%" stopColor="#4F5BD5" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FacebookIcon = () => (
|
A band with nothing to show draws nothing: the countdown with no
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
|
upcoming event, the numbers with no numbers, the timeline with
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
nothing featured, the pathfinder with no paths.
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DiscordIcon = () => (
|
If /front-page fails, the hero still draws (empty) and the error
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
|
takes the place of the bands, with a retry. There's deliberately
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
no default page to fall back to: it would look fine and hide a
|
||||||
</svg>
|
broken server.
|
||||||
);
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
{/* Link Tables */}
|
import type { ReactNode } from 'react'
|
||||||
const Social_Links = [
|
|
||||||
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
|
|
||||||
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
|
|
||||||
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
|
|
||||||
]
|
|
||||||
|
|
||||||
const Footer_Links = [
|
import HeroStage from './sections/home/HeroStage.tsx'
|
||||||
{ label: "Privacy Policy", href: "#"},
|
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
|
||||||
{ label: "Terms of Service", href: "#"},
|
import RetreatsBand from './sections/home/RetreatsBand.tsx'
|
||||||
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
|
import CalendarBand from './sections/home/CalendarBand.tsx'
|
||||||
]
|
import StatsBand from './sections/home/StatsBand.tsx'
|
||||||
|
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
|
||||||
|
import Pathfinder from './sections/home/Pathfinder.tsx'
|
||||||
|
import {
|
||||||
|
useFrontPage,
|
||||||
|
type FrontPage,
|
||||||
|
type FrontPageSection,
|
||||||
|
type FrontPageSectionKey,
|
||||||
|
} from '../lib/useFrontPage.ts'
|
||||||
|
import './sections/home/home.css'
|
||||||
|
|
||||||
const EVENTS = [
|
type BandProps = {
|
||||||
{
|
page: FrontPage
|
||||||
id: "spring-2025",
|
section: FrontPageSection
|
||||||
title: "Spring Retreat 2026",
|
title: string
|
||||||
theme: "Altering Intertia",
|
/** Position among the visible bands, 0 = straight after the hero. */
|
||||||
date: "March/April 2026",
|
position: number
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: fallLogo, // e.g. springLogo
|
|
||||||
color: "#f1c2fe",
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a:
|
|
||||||
"A weekend of connection, workshops, and community for young adults across the Unity movement.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "past",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fall-retreat-2026",
|
|
||||||
title: "Fall Retreat 2026",
|
|
||||||
theme: "Consciousness Creates",
|
|
||||||
date: "November 12-15th, 2026",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: fallLogo,
|
|
||||||
color: "#b89421",
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a:
|
|
||||||
"Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
|
|
||||||
desc_b: "Registration starting at $150, and $75 lodging cost.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{ label: "Register Now!", link: "https://ngu.churchcenter.com/registrations/events/3761999" },
|
|
||||||
{ label: "Scholarship Application", link: "https://ngu.churchcenter.com/people/forms/1261992" },
|
|
||||||
{ label: "Volunteer", link: "https://ngu.churchcenter.com/people/forms/1176908" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-recharge-2027",
|
|
||||||
title: "Spring Recharge 2027",
|
|
||||||
theme: "TBD",
|
|
||||||
date: "March 6th, 2027",
|
|
||||||
location: "Online",
|
|
||||||
image: null, // e.g. eventLogo
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient:
|
|
||||||
null,
|
|
||||||
desc_a:
|
|
||||||
"One-day online event to reconnect in the spring.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-service-2027",
|
|
||||||
title: "Service Week 2027",
|
|
||||||
theme: "Leadership & Service",
|
|
||||||
date: "April 4-9th, 2027",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: null, // e.g. eventLogo
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient:
|
|
||||||
null,
|
|
||||||
desc_a:
|
|
||||||
"Join us at beautiful Unity Village for a week of leadership development and service projects.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const START_INDEX = (() => {
|
|
||||||
const i = EVENTS.findIndex(e => e.status === "upcoming");
|
|
||||||
return i === -1 ? EVENTS.length - 1 : i;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const TEAL = "#138ba0";
|
|
||||||
|
|
||||||
const CARD = "min(48rem, 90vw)"; // the card itself — your original max-w-3xl
|
|
||||||
const GAP = "5rem"; // space between cards ← this is your knob
|
|
||||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
|
||||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
|
||||||
const FADE_DIST = "18rem";
|
|
||||||
|
|
||||||
const EDGE_FADE = `linear-gradient(to right,
|
|
||||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
|
||||||
black calc(50% - ${HALF_SLIDE}),
|
|
||||||
black calc(50% + ${HALF_SLIDE}),
|
|
||||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
|
||||||
|
|
||||||
{/* Functions */}
|
|
||||||
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.split("").map((char, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="float-anim"
|
|
||||||
style={{ animationDelay: `${-i * step}s` }}
|
|
||||||
>
|
|
||||||
{char === " " ? "\u00A0" : char}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Default headings for a band the admin didn't title, and how each
|
||||||
|
one renders. The ids are the page anchors — #connect is what the
|
||||||
|
hero's second button points at out of the box. */
|
||||||
|
const SECTIONS: Record<
|
||||||
|
FrontPageSectionKey,
|
||||||
|
{ title: string; render: (props: BandProps) => ReactNode }
|
||||||
|
> = {
|
||||||
|
countdown: {
|
||||||
|
title: 'Next up',
|
||||||
|
render: ({ page, position }) =>
|
||||||
|
page.countdown && <NextEventCountdown event={page.countdown} overlap={position === 0} />,
|
||||||
|
},
|
||||||
|
retreats: {
|
||||||
|
title: 'National Retreats',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<RetreatsBand id="retreats" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
title: 'What’s on',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<CalendarBand id="calendar" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
title: 'By the numbers',
|
||||||
|
render: ({ page, section, title }) =>
|
||||||
|
page.stats.length > 0 && (
|
||||||
|
<StatsBand id="numbers" title={title} blurb={section.blurb} stats={page.stats} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
title: 'Our story so far',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<FeaturedTimelineRail id="story" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
connect: {
|
||||||
|
title: 'Find your way in',
|
||||||
|
render: ({ page, section, title }) => (
|
||||||
|
<Pathfinder id="connect" title={title} blurb={section.blurb} paths={page.paths} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
{/* Other useState consts and Functions*/}
|
const { data: page, error, reload } = useFrontPage()
|
||||||
const [showCalendar, setShowCalendar] = useState(false);
|
|
||||||
const [index, setIndex] = useState(START_INDEX);
|
|
||||||
|
|
||||||
const event = EVENTS[index];
|
|
||||||
const isPast = event.status === "past";
|
|
||||||
const currentColor = event.color || TEAL;
|
|
||||||
|
|
||||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
|
||||||
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
|
|
||||||
|
|
||||||
const arrowStyle = enabled => ({
|
|
||||||
border: `1px solid ${TEAL}`,
|
|
||||||
background: "rgba(255,255,255,0.6)",
|
|
||||||
color: enabled ? TEAL : "#b8c6c9",
|
|
||||||
cursor: enabled ? "pointer" : "default",
|
|
||||||
opacity: enabled ? 1 : 0.4,
|
|
||||||
});
|
|
||||||
|
|
||||||
{/* Start of Main Content*/}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
|
<HeroStage hero={page?.hero ?? null} />
|
||||||
<section id="hero" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
|
|
||||||
{/* Floating doves */}
|
|
||||||
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
|
|
||||||
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
|
|
||||||
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
{error && (
|
||||||
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
|
<section className="px-6 py-24 text-center">
|
||||||
Next Generation<br />
|
<p className="text-[#b3261e]">Couldn’t load the front page. {error}</p>
|
||||||
<span className="grad-hero-text">
|
<button
|
||||||
<WaveText text="of Unity" step={0.1} />
|
type="button"
|
||||||
</span>
|
onClick={reload}
|
||||||
</h1>
|
className="mt-4 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] hover:bg-[#eef9fb]"
|
||||||
|
|
||||||
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
|
|
||||||
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
|
|
||||||
</p>
|
|
||||||
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
|
|
||||||
We are the future of the Unity Movement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 justify-center">
|
|
||||||
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Follow Us on Instagram
|
|
||||||
</a>
|
|
||||||
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Attend a Retreat
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* #About ─────────────────────────────────────── */}
|
|
||||||
<section id="about" className="py-24 px-6" style={{ background: "#f0fcfd" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
|
|
||||||
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
|
||||||
{[
|
|
||||||
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
|
|
||||||
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
|
|
||||||
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
|
|
||||||
].map((card) => (
|
|
||||||
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
|
|
||||||
<div className="text-4xl mb-4">{card.icon}</div>
|
|
||||||
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
|
|
||||||
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* #Events ─────────────────────────────────────────────── */}
|
|
||||||
<section id="events" className="py-24 overflow-hidden" style={{ background: "#eef9fb" }}>
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
|
|
||||||
{isPast ? "Past Events" : "Upcoming Events"}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
|
|
||||||
<div className="relative mb-6">
|
|
||||||
{/* Masked viewport: everything outside the fade gradient is invisible */}
|
|
||||||
<div
|
|
||||||
className="overflow-hidden"
|
|
||||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
|
||||||
>
|
>
|
||||||
{/* Sliding track */}
|
Try again
|
||||||
<div
|
</button>
|
||||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
</section>
|
||||||
style={{
|
)}
|
||||||
transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))`,
|
|
||||||
}}
|
{page?.sections.map((section, position) => {
|
||||||
>
|
const band = SECTIONS[section.section]
|
||||||
{EVENTS.map((ev, i) => {
|
// A key the CHECK has gained since this file was written.
|
||||||
const active = i === index;
|
if (!band) return null
|
||||||
const past = ev.status === "past";
|
|
||||||
const color = ev.color || TEAL;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={section.section}>
|
||||||
key={ev.id}
|
{band.render({
|
||||||
className="shrink-0"
|
page,
|
||||||
style={{
|
section,
|
||||||
width: SLIDE,
|
title: section.title || band.title,
|
||||||
padding: `0 calc(${GAP} / 2)`,
|
position,
|
||||||
cursor: active ? "default" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => !active && setIndex(i)}
|
|
||||||
aria-hidden={!active}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-3xl text-black overflow-hidden shadow-2xl h-full"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
background: ev.gradient,
|
|
||||||
filter: past ? "saturate(0.75)" : "none",
|
|
||||||
pointerEvents: active ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-10">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 mb-4">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<img
|
|
||||||
src={nguLogo_WhiteBG}
|
|
||||||
alt="Next Generation of Unity"
|
|
||||||
className="h-15 w-auto mb-6"
|
|
||||||
/>
|
|
||||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
|
||||||
{ev.theme && (
|
|
||||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
|
||||||
)}
|
|
||||||
<p className="text-2xl">{ev.date}</p>
|
|
||||||
<p className="text-2xl">{ev.location}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-1 flex justify-end items-start">
|
|
||||||
{ev.image && (
|
|
||||||
<img src={ev.image} alt={ev.title} className="h-60" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ev.desc_a && <p className="mb-2 leading-relaxed">{ev.desc_a}</p>}
|
|
||||||
{ev.desc_b && <p className="leading-relaxed">{ev.desc_b}</p>}
|
|
||||||
|
|
||||||
{ev.links.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-8">
|
|
||||||
{ev.links.map(item => (
|
|
||||||
<a
|
|
||||||
key={item.label}
|
|
||||||
href={item.link}
|
|
||||||
className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center"
|
|
||||||
style={{ border: `1px solid ${color}` }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
past ? (
|
|
||||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
|
||||||
This event has concluded, thank you to everyone who joined us!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
|
||||||
Registration has not opened yet, follow our instagram for more details.
|
|
||||||
</p>
|
|
||||||
<a href="https://instagram.com/nextgenerationunity" target="_blank" rel="noopener noreferrer" className="ig-link mt-4 w-fit mx-auto flex items-center justify-center gap-3 py-3 px-4 rounded-xl font-700 transition-all duration-200 hover:scale-[1.02]" style={{ border: `1px solid ${color}`, color }}>
|
|
||||||
<InstagramIcon id={`ig-${ev.id}`} />
|
|
||||||
@nextgenerationunity
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
{/* Arrows — outside the masked element so they never fade */}
|
|
||||||
<button
|
|
||||||
onClick={prev}
|
|
||||||
disabled={index === 0}
|
|
||||||
aria-label="Previous event"
|
|
||||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
|
||||||
style={arrowStyle(index > 0)}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={next}
|
|
||||||
disabled={index === EVENTS.length - 1}
|
|
||||||
aria-label="Next event"
|
|
||||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
|
||||||
style={arrowStyle(index < EVENTS.length - 1)}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Dot indicators */}
|
|
||||||
<div className="flex justify-center gap-2 mb-10">
|
|
||||||
{EVENTS.map((e, i) => (
|
|
||||||
<button
|
|
||||||
key={e.id}
|
|
||||||
onClick={() => setIndex(i)}
|
|
||||||
aria-label={`Go to ${e.title}`}
|
|
||||||
className="h-2.5 rounded-full transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
width: i === index ? "1.5rem" : "0.625rem",
|
|
||||||
background: i === index ? currentColor : "#b8c6c9",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-[#138ba0] font-600 text-sm">
|
|
||||||
· More events coming soon, stay connected for announcements ·
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* #Connect -------------------------------------------- */}
|
|
||||||
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Ready to Connect?
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
|
|
||||||
{[
|
|
||||||
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
|
|
||||||
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
|
|
||||||
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
|
|
||||||
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
|
|
||||||
].map(item => (
|
|
||||||
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
|
|
||||||
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
{/* Arrow */}
|
|
||||||
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 text-center">
|
|
||||||
<div className="inline-flex flex-wrap justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://ngu.churchcenter.com/calendar?view=gallery"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105"
|
|
||||||
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}
|
|
||||||
>
|
|
||||||
📅 View NGU Calendar
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
||||||
<polyline points="15 3 21 3 21 9" />
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCalendar(!showCalendar)}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105"
|
|
||||||
style={{ border: "2px solid #138ba0", color: "#138ba0", background: "transparent", fontFamily: "Outfit,sans-serif" }}
|
|
||||||
>
|
|
||||||
{showCalendar ? "▲ Hide Calendar" : "▼ Show Calendar Here"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCalendar && (
|
|
||||||
<div className="mt-8 mx-auto max-w-4xl rounded-2xl shadow-lg p-6" style={{ border: "2px solid #138ba0" }}>
|
|
||||||
<iframe
|
|
||||||
src="https://ngu.churchcenter.com/calendar?embed=true&view=month"
|
|
||||||
title="NGU Calendar"
|
|
||||||
className="w-full planning-center-calender-embed"
|
|
||||||
style={{ height: "700px", border: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
444
src/pages/OrganizationDetail.tsx
Normal file
444
src/pages/OrganizationDetail.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ORGANIZATION DETAIL
|
||||||
|
/regions/:id /chapters/:id /partners/:id /organizations/:id
|
||||||
|
|
||||||
|
One component behind four routes, because organizations are one
|
||||||
|
table. The kind decides which extras render, not which file runs.
|
||||||
|
|
||||||
|
The URL kind is decoration — the slug is what identifies the
|
||||||
|
record — so a request for /chapters/great-lakes when that slug is
|
||||||
|
a region redirects to the canonical path rather than rendering a
|
||||||
|
correct page at a wrong address.
|
||||||
|
|
||||||
|
Leadership arrives flat with team_id and team_name on each row,
|
||||||
|
so grouping costs nothing. `teams` is fetched alongside anyway:
|
||||||
|
a team with no current public members would otherwise be
|
||||||
|
invisible here instead of listed, and its page unreachable.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||||
|
import EventListCards from './sections/EventList-Cards.tsx'
|
||||||
|
import {
|
||||||
|
useOrganization,
|
||||||
|
type Leader,
|
||||||
|
type OrganizationRecord,
|
||||||
|
} from '../lib/useContent.ts'
|
||||||
|
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
|
||||||
|
import { orgLogo, personPhoto } from '../lib/media.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function OrganizationDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const { data: org, loading, error, notFound, reload } = useOrganization(id)
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="organization"
|
||||||
|
backTo="/community"
|
||||||
|
backLabel="Community"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /chapters/x when x is a region: same record, wrong address.
|
||||||
|
//
|
||||||
|
// useLocation, not window.location: the latter is outside the
|
||||||
|
// router's awareness — always "/" under HashRouter, and free to
|
||||||
|
// be stale mid-navigation, either of which turns this into a
|
||||||
|
// redirect loop rather than a one-shot correction.
|
||||||
|
//
|
||||||
|
// Guarded on org.id because a record with no id would redirect to
|
||||||
|
// /chapters/undefined, which is a worse page than the one we're
|
||||||
|
// already on. All four organization routes must be registered or
|
||||||
|
// this redirects somewhere nothing matches.
|
||||||
|
const canonical = org.id ? orgHref(org.id, org.kind) : null
|
||||||
|
if (canonical && decodeURIComponent(pathname) !== canonical) {
|
||||||
|
return <Navigate to={canonical} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = org.color || TEAL
|
||||||
|
const back = orgListHref(org.kind)
|
||||||
|
|
||||||
|
const sections: any[] = [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts org={org} accent={accent} back={back} />
|
||||||
|
|
||||||
|
{org.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={org.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Contact org={org} accent={accent} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const teamBlocks = groupLeadership(org)
|
||||||
|
|
||||||
|
if (teamBlocks.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'leadership',
|
||||||
|
title: 'Who runs it',
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-12">
|
||||||
|
{teamBlocks.map((block) => (
|
||||||
|
<div key={block.key}>
|
||||||
|
<div className="mb-4">
|
||||||
|
{block.teamId ? (
|
||||||
|
<h3 className="text-xl font-bold">
|
||||||
|
<Link
|
||||||
|
to={teamHref(block.teamId)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{block.label}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
block.label && (
|
||||||
|
<h3 className="text-xl font-bold" style={{ color: accent }}>
|
||||||
|
{block.label}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{block.tagline && (
|
||||||
|
<p className="mt-1 text-sm" style={{ color: BODY }}>
|
||||||
|
{block.tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{block.people.length > 0 ? (
|
||||||
|
<PeopleTiles
|
||||||
|
size="lg"
|
||||||
|
accent={block.accent || accent}
|
||||||
|
people={block.people.map((leader) => ({
|
||||||
|
id: leader.person_id,
|
||||||
|
name: leader.display_name,
|
||||||
|
title: leader.title,
|
||||||
|
pronouns: leader.pronouns,
|
||||||
|
public_email: leader.public_email,
|
||||||
|
photo: personPhoto(leader.photo),
|
||||||
|
is_owner: leader.is_owner,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic" style={{ color: BODY }}>
|
||||||
|
No members listed yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'chapters',
|
||||||
|
title: 'Chapters',
|
||||||
|
blurb: org.details.map_note ?? undefined,
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{(org.details.chapters ?? []).map((chapter) => (
|
||||||
|
<Link
|
||||||
|
key={chapter.id}
|
||||||
|
to={orgHref(chapter.id, 'chapter')}
|
||||||
|
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: `${accent}40` }}
|
||||||
|
>
|
||||||
|
<Logo file={chapter.logo} name={chapter.name} accent={accent} />
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold" style={{ color: accent }}>
|
||||||
|
{chapter.name}
|
||||||
|
</span>
|
||||||
|
{chapter.location_label && (
|
||||||
|
<span className="block text-sm" style={{ color: BODY }}>
|
||||||
|
{chapter.location_label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.awards.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'awards',
|
||||||
|
title: 'Awards',
|
||||||
|
blurb: `Given by ${org.short_name || org.name}.`,
|
||||||
|
accent,
|
||||||
|
background: '#eef9fb',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
{org.awards.map((award) => (
|
||||||
|
<Link
|
||||||
|
key={award.id}
|
||||||
|
to={`/awards/${award.id}`}
|
||||||
|
className="rounded-lg border bg-white p-5 transition-colors hover:bg-[#f6fbfc]"
|
||||||
|
style={{ borderColor: `${accent}40` }}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold" style={{ color: accent }}>
|
||||||
|
{award.name}
|
||||||
|
</span>
|
||||||
|
{award.description && (
|
||||||
|
<span className="mt-1 block text-sm" style={{ color: BODY }}>
|
||||||
|
{award.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="mt-3 block text-xs uppercase tracking-wide opacity-70" style={{ color: BODY }}>
|
||||||
|
{award.recipient_count === 0
|
||||||
|
? 'No recipients yet'
|
||||||
|
: `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventList-Cards fetches and renders this itself — its own
|
||||||
|
// docstring offers `host` for exactly this case, and a card there
|
||||||
|
// already knows about gradients, logos, past-event collapsing and
|
||||||
|
// the carousel. A second grid here was the same component written
|
||||||
|
// worse.
|
||||||
|
//
|
||||||
|
// `org.events` is still what decides whether the section exists at
|
||||||
|
// all: an organization that has never hosted anything shouldn't
|
||||||
|
// get a heading followed by "events coming soon".
|
||||||
|
if (org.events.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'events',
|
||||||
|
title: 'Gatherings',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<EventListCards
|
||||||
|
host={org.id}
|
||||||
|
view="grid"
|
||||||
|
accent={accent}
|
||||||
|
empty="· Nothing on the calendar right now ·"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageShell title={org.name} intro={org.tagline ?? undefined} sections={sections} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pieces ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function Facts({
|
||||||
|
org,
|
||||||
|
accent,
|
||||||
|
back,
|
||||||
|
}: {
|
||||||
|
org: OrganizationRecord
|
||||||
|
accent: string
|
||||||
|
back: { to: string; label: string }
|
||||||
|
}) {
|
||||||
|
const where =
|
||||||
|
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||||
|
|
||||||
|
{org.kind === 'region' && org.details.scope && (
|
||||||
|
<span style={{ color: BODY }}>{org.details.scope}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.kind === 'chapter' && org.details.region_id && (
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
Part of{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(org.details.region_id, 'region')}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: org.details.region_color || accent }}
|
||||||
|
>
|
||||||
|
{org.details.region_name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.kind === 'chapter' && org.details.meets && (
|
||||||
|
<span style={{ color: BODY }}>Meets {org.details.meets}</span>
|
||||||
|
)}
|
||||||
|
{org.kind === 'chapter' && org.details.started && (
|
||||||
|
<span style={{ color: BODY }}>Since {org.details.started}</span>
|
||||||
|
)}
|
||||||
|
{org.is_online && <span style={{ color: BODY }}>Online</span>}
|
||||||
|
|
||||||
|
<Link to={back.to} className="ml-auto hover:underline" style={{ color: accent }}>
|
||||||
|
{back.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
||||||
|
const hasAny =
|
||||||
|
org.links.length > 0 || org.socials.length > 0 || org.website || org.email
|
||||||
|
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{org.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* website and email arrive as bare strings (splitLinks in
|
||||||
|
shape.js); socials are whole link rows. */}
|
||||||
|
{[
|
||||||
|
org.website ? { url: org.website, label: 'Website' } : null,
|
||||||
|
org.email ? { url: `mailto:${org.email}`, label: org.email } : null,
|
||||||
|
...org.socials,
|
||||||
|
]
|
||||||
|
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||||
|
.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logo({
|
||||||
|
file,
|
||||||
|
name,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
file?: string | null
|
||||||
|
name: string
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const src = orgLogo(file)
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <img src={src} alt="" loading="lazy" className="h-10 w-10 shrink-0 object-contain" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leadership → team blocks ────────────────────────────────── */
|
||||||
|
|
||||||
|
type TeamBlock = {
|
||||||
|
key: string
|
||||||
|
teamId: string | null
|
||||||
|
label: string | null
|
||||||
|
tagline?: string | null
|
||||||
|
accent?: string | null
|
||||||
|
people: Leader[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Order comes from `teams` (the admin's sort_order), not from the
|
||||||
|
order people happen to appear in. Three cases to cover:
|
||||||
|
affiliations with no team at all, teams with nobody in them, and
|
||||||
|
people filed under a team that is no longer published. */
|
||||||
|
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
|
||||||
|
const byTeam = new Map<string, Leader[]>()
|
||||||
|
const loose: Leader[] = []
|
||||||
|
|
||||||
|
for (const leader of org.leadership) {
|
||||||
|
if (!leader.team_id) {
|
||||||
|
loose.push(leader)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const list = byTeam.get(leader.team_id)
|
||||||
|
if (list) list.push(leader)
|
||||||
|
else byTeam.set(leader.team_id, [leader])
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: TeamBlock[] = []
|
||||||
|
|
||||||
|
// People who hold a role in the organization without sitting on a
|
||||||
|
// team. Usually the leads. No heading — they are the page.
|
||||||
|
if (loose.length > 0) {
|
||||||
|
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of org.teams) {
|
||||||
|
blocks.push({
|
||||||
|
key: team.id,
|
||||||
|
teamId: team.id,
|
||||||
|
label: team.name,
|
||||||
|
tagline: team.tagline,
|
||||||
|
accent: team.color,
|
||||||
|
people: byTeam.get(team.id) ?? [],
|
||||||
|
})
|
||||||
|
byTeam.delete(team.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever is left is filed under an unpublished team. Its page
|
||||||
|
// isn't reachable, so the heading is plain text — but the people
|
||||||
|
// are real and shouldn't silently vanish from the org.
|
||||||
|
for (const [teamId, people] of byTeam) {
|
||||||
|
blocks.push({
|
||||||
|
key: teamId,
|
||||||
|
teamId: null,
|
||||||
|
label: people[0]?.team_name ?? null,
|
||||||
|
people,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
363
src/pages/PersonDetail.tsx
Normal file
363
src/pages/PersonDetail.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PERSON DETAIL — /people/:id
|
||||||
|
|
||||||
|
Everything public that points at one person, gathered by
|
||||||
|
GET /people/:id in people.js. Visibility is decided there, the
|
||||||
|
same way each list's own page decides it, so nothing here
|
||||||
|
filters.
|
||||||
|
|
||||||
|
Roles are current and past. A roster only ever wants who holds
|
||||||
|
a seat now; a person's record is also where "served on the board
|
||||||
|
2018–2022" belongs, so ended affiliations list under Previously.
|
||||||
|
|
||||||
|
public_phone is never sent. The email is the one contact detail
|
||||||
|
the page offers.
|
||||||
|
|
||||||
|
Sections after About alternate background in the order they
|
||||||
|
appear, so a person with no roles doesn't get two tinted bands
|
||||||
|
in a row.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import {
|
||||||
|
usePerson,
|
||||||
|
type PersonEvent,
|
||||||
|
type PersonRecord,
|
||||||
|
type PersonRole,
|
||||||
|
} from '../lib/useContent.ts'
|
||||||
|
import { awardHref, eventHref, orgHref, teamHref } from '../lib/hrefs.ts'
|
||||||
|
import { initials, personPhoto } from '../lib/media.ts'
|
||||||
|
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
const BACKGROUNDS = ['#ffffff', '#eef9fb']
|
||||||
|
|
||||||
|
/* affiliations.role, for a row with no title of its own. */
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
lead: 'Lead',
|
||||||
|
board: 'Board member',
|
||||||
|
staff: 'Staff',
|
||||||
|
volunteer: 'Volunteer',
|
||||||
|
member: 'Member',
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (word: string) =>
|
||||||
|
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||||
|
|
||||||
|
export default function PersonDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: person, loading, error, notFound, reload } = usePerson(id)
|
||||||
|
|
||||||
|
if (!person) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="person"
|
||||||
|
backTo="/leadership"
|
||||||
|
backLabel="Leadership"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = TEAL
|
||||||
|
const current = person.roles.filter((role) => !role.ended_on)
|
||||||
|
const previous = person.roles.filter((role) => role.ended_on)
|
||||||
|
|
||||||
|
const sections: ShellSection[] = [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
accent,
|
||||||
|
background: BACKGROUNDS[0],
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts person={person} accent={accent} />
|
||||||
|
|
||||||
|
{[...person.bio, ...person.description].map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={person.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Contact person={person} accent={accent} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (person.roles.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'roles',
|
||||||
|
title: 'Roles',
|
||||||
|
accent,
|
||||||
|
background: BACKGROUNDS[sections.length % 2],
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
{current.length > 0 && <RoleList roles={current} accent={accent} />}
|
||||||
|
|
||||||
|
{previous.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{current.length > 0 && (
|
||||||
|
<h3 className="mb-4 text-lg font-semibold" style={{ color: accent }}>
|
||||||
|
Previously
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<RoleList roles={previous} accent={accent} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.events.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'events',
|
||||||
|
title: 'Events',
|
||||||
|
accent,
|
||||||
|
background: BACKGROUNDS[sections.length % 2],
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
<ul className="space-y-5">
|
||||||
|
{person.events.map((event) => (
|
||||||
|
<li key={event.id}>
|
||||||
|
<EventRow event={event} accent={accent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.awards.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: 'awards',
|
||||||
|
title: 'Awards',
|
||||||
|
accent,
|
||||||
|
background: BACKGROUNDS[sections.length % 2],
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||||
|
{person.awards.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.award.id}:${entry.awarded_on ?? index}`}
|
||||||
|
className="border-l-2 pl-5"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
<Link to={awardHref(entry.award.id)} className="hover:underline">
|
||||||
|
{entry.award.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{(entry.awarded_on || entry.event) && (
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{year(entry.awarded_on)}
|
||||||
|
{entry.event && (
|
||||||
|
<>
|
||||||
|
{entry.awarded_on && ' · '}
|
||||||
|
<Link
|
||||||
|
to={eventHref(entry.event.id)}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{entry.event.title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entry.citation && (
|
||||||
|
<p className="mt-1 max-w-2xl italic leading-relaxed" style={{ color: BODY }}>
|
||||||
|
{entry.citation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={person.name} intro={person.tagline ?? undefined} sections={sections} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The strip of facts under the heading ────────────────────── */
|
||||||
|
|
||||||
|
function Facts({ person, accent }: { person: PersonRecord; accent: string }) {
|
||||||
|
const photo = personPhoto(person.photo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
{photo ? (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="h-24 w-24 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-24 w-24 shrink-0 items-center justify-center rounded-full text-2xl font-bold text-white"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{initials(person.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{person.pronouns && <span style={{ color: BODY }}>{person.pronouns}</span>}
|
||||||
|
|
||||||
|
{person.org && (
|
||||||
|
<Link
|
||||||
|
to={orgHref(person.org.id, person.org.kind)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{person.org.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{person.location_label && <span style={{ color: BODY }}>{person.location_label}</span>}
|
||||||
|
|
||||||
|
<Link to="/leadership" className="ml-auto hover:underline" style={{ color: accent }}>
|
||||||
|
Leadership
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Contact({ person, accent }: { person: PersonRecord; accent: string }) {
|
||||||
|
const outlined = [
|
||||||
|
person.website ? { url: person.website, label: 'Website' } : null,
|
||||||
|
person.public_email
|
||||||
|
? { url: `mailto:${person.public_email}`, label: person.public_email }
|
||||||
|
: null,
|
||||||
|
...person.socials,
|
||||||
|
].filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||||
|
|
||||||
|
if (person.links.length === 0 && outlined.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{person.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{outlined.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Roles ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function RoleList({ roles, accent }: { roles: PersonRole[]; accent: string }) {
|
||||||
|
return (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{roles.map((role, index) => (
|
||||||
|
<li
|
||||||
|
key={`${role.org.id}:${role.team?.id ?? ''}:${role.title ?? role.role}:${index}`}
|
||||||
|
className="rounded-xl border-l-4 bg-white px-5 py-3"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
{role.title || ROLE_LABEL[role.role] || capitalize(role.role)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{role.team && (
|
||||||
|
<>
|
||||||
|
<Link to={teamHref(role.team.id)} className="hover:underline">
|
||||||
|
{role.team.name}
|
||||||
|
</Link>
|
||||||
|
{' · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link to={orgHref(role.org.id, role.org.kind)} className="hover:underline">
|
||||||
|
{role.org.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{tenure(role) && (
|
||||||
|
<p className="text-xs" style={{ color: BODY }}>
|
||||||
|
{tenure(role)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "2018 – 2022", "Since 2021", "Until 2019", or null. Years only:
|
||||||
|
affiliation dates are often backfilled from memory. */
|
||||||
|
function tenure(role: PersonRole): string | null {
|
||||||
|
const from = year(role.started_on)
|
||||||
|
const to = year(role.ended_on)
|
||||||
|
if (from && to) return from === to ? from : `${from} – ${to}`
|
||||||
|
if (from) return `Since ${from}`
|
||||||
|
if (to) return `Until ${to}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Events ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function EventRow({ event, accent }: { event: PersonEvent; accent: string }) {
|
||||||
|
const when = event.date_label || year(event.starts_on)
|
||||||
|
const capacities = event.roles
|
||||||
|
.map((role) => role.title || capitalize(role.role))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-l-2 pl-5" style={{ borderColor: accent }}>
|
||||||
|
<p className="font-semibold" style={{ color: accent }}>
|
||||||
|
<Link to={eventHref(event.id)} className="hover:underline">
|
||||||
|
{event.title}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: BODY }}>
|
||||||
|
{[capacities, eventTypeLabel(event.event_type), when].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dates here may be partial ('2019', '2019-06'), so take the
|
||||||
|
leading year rather than parsing. */
|
||||||
|
function year(date?: string | null): string | null {
|
||||||
|
if (!date) return null
|
||||||
|
const match = /^(\d{4})/.exec(date)
|
||||||
|
return match ? match[1] : date
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
|
||||||
different filter. The page declares them and does nothing else —
|
different filter. The page declares them and does nothing else —
|
||||||
each section fetches its own slice.
|
each section fetches its own slice.
|
||||||
|
|
||||||
|
Two filters per band now, and they answer different questions:
|
||||||
|
|
||||||
|
scope whose gathering it is. event_scopes holds six of
|
||||||
|
these; this page draws three.
|
||||||
|
type what kind of gathering it is. Pinned to "retreat"
|
||||||
|
everywhere on this page, which is what the page is
|
||||||
|
for and what lets "Partner" read as a heading rather
|
||||||
|
than a catch-all.
|
||||||
|
|
||||||
|
Because every band is pinned to one type, EventListCards finds a
|
||||||
|
single kind in each and draws no type chips. Drop the `type` from
|
||||||
|
a band and the chips appear on their own.
|
||||||
|
|
||||||
|
What this page deliberately does not show: local, international
|
||||||
|
and other scopes, and every non-retreat type. Those are reachable
|
||||||
|
from their host's page and by URL, and get a band here when
|
||||||
|
there's enough of them to fill one — see the block at the bottom.
|
||||||
|
|
||||||
To reorder the page, move a line. To add a different kind of
|
To reorder the page, move a line. To add a different kind of
|
||||||
section, add an entry with another Component.
|
section, add an entry with another Component.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||||
|
|
||||||
|
/* Pinned on every band. Named rather than repeated so turning this
|
||||||
|
page into "everything, filtered" later is one deletion. */
|
||||||
|
const RETREATS = { type: "retreat" };
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
id: "national",
|
id: "national",
|
||||||
|
|
@ -23,7 +45,7 @@ const SECTIONS = [
|
||||||
accent: "#138ba0",
|
accent: "#138ba0",
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "national" },
|
props: { scope: "national", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "carousel" },
|
views: { ...CARD_VIEWS, default: "carousel" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -33,19 +55,56 @@ const SECTIONS = [
|
||||||
accent: "#aac992",
|
accent: "#aac992",
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "regional" },
|
props: { scope: "regional", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "partner",
|
id: "partner",
|
||||||
title: "Partner Events",
|
title: "Partner Events",
|
||||||
blurb: "Events hosted by organizations we collaborate with.",
|
blurb: "Retreats hosted by organizations we collaborate with.",
|
||||||
accent: "#7a5ea8",
|
accent: "#7a5ea8",
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "partner" },
|
props: { scope: "partner", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* The other three scopes, ready to uncomment. Each needs an accent
|
||||||
|
and a background of its own — those are presentation and live
|
||||||
|
here, not in event_scopes.
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "local",
|
||||||
|
title: "Local Events",
|
||||||
|
blurb: "Hosted by individual chapters.",
|
||||||
|
accent: "#d08a3c",
|
||||||
|
background: "#ffffff",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { scope: "local", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "international",
|
||||||
|
title: "International Events",
|
||||||
|
blurb: "Gatherings beyond the US.",
|
||||||
|
accent: "#3c7fd0",
|
||||||
|
background: "#eef9fb",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { scope: "international", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
title: "Other Events",
|
||||||
|
blurb: "Everything that doesn't fit the categories above.",
|
||||||
|
accent: "#7a8a8e",
|
||||||
|
background: "#ffffff",
|
||||||
|
Component: EventListCards,
|
||||||
|
props: { scope: "other", ...RETREATS_ONLY },
|
||||||
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
|
},
|
||||||
|
|
||||||
|
*/
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RetreatsPage() {
|
export default function RetreatsPage() {
|
||||||
|
|
@ -54,7 +113,7 @@ export default function RetreatsPage() {
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell
|
||||||
title="Retreats"
|
title="Retreats"
|
||||||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
intro="Retreats hosted by Next Generation of Unity, our regions, and our partners throughout the year."
|
||||||
sections={sections}
|
sections={sections}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
155
src/pages/TeamDetail.tsx
Normal file
155
src/pages/TeamDetail.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TEAM DETAIL — /teams/:id
|
||||||
|
|
||||||
|
teams.id is a global primary key rather than scoped to the
|
||||||
|
organization, which is what lets this route be flat: 'ngu-board'
|
||||||
|
can only mean one thing site-wide.
|
||||||
|
|
||||||
|
The roster is not in this page's data. PeopleTiles fetches
|
||||||
|
/teams/:id/people itself, off v_org_leadership, which already
|
||||||
|
decides who counts as current and public. Two requests, both
|
||||||
|
cached for 60s by api.ts, and one set of visibility rules.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PageShell from '../components/PageShell.tsx'
|
||||||
|
import PageState from '../components/PageState.tsx'
|
||||||
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
|
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||||
|
import { useTeam } from '../lib/useContent.ts'
|
||||||
|
import { orgHref } from '../lib/hrefs.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
export default function TeamDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data: team, loading, error, notFound, reload } = useTeam(id)
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<PageState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
notFound={notFound}
|
||||||
|
onRetry={reload}
|
||||||
|
noun="team"
|
||||||
|
backTo="/leadership"
|
||||||
|
backLabel="Leadership"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = team.color || TEAL
|
||||||
|
const hasAbout =
|
||||||
|
team.description.length > 0 || team.blocks.length > 0 || team.links.length > 0
|
||||||
|
|
||||||
|
const sections: any[] = []
|
||||||
|
|
||||||
|
if (hasAbout) {
|
||||||
|
sections.push({
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
accent,
|
||||||
|
background: '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||||
|
<Facts orgId={team.org.id} orgKind={team.org.kind} orgName={team.org.name} accent={accent} />
|
||||||
|
|
||||||
|
{team.description.map((paragraph, index) => (
|
||||||
|
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<ContentBlocks blocks={team.blocks} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{team.links.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{team.links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||||
|
style={{ background: accent }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
id: 'members',
|
||||||
|
title: hasAbout ? 'Members' : team.name,
|
||||||
|
accent,
|
||||||
|
background: hasAbout ? '#eef9fb' : '#ffffff',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||||
|
{!hasAbout && (
|
||||||
|
<Facts
|
||||||
|
orgId={team.org.id}
|
||||||
|
orgKind={team.org.kind}
|
||||||
|
orgName={team.org.name}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PeopleTiles
|
||||||
|
size="lg"
|
||||||
|
accent={accent}
|
||||||
|
teams={[{ id: team.id, label: 'Members', accent }]}
|
||||||
|
emptyMessage="Nobody is currently listed on this team."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={team.name} intro={team.tagline ?? undefined} sections={sections} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Facts({
|
||||||
|
orgId,
|
||||||
|
orgKind,
|
||||||
|
orgName,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
orgId: string
|
||||||
|
orgKind?: string | null
|
||||||
|
orgName: string
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||||
|
<span style={{ color: BODY }}>
|
||||||
|
A team of{' '}
|
||||||
|
<Link
|
||||||
|
to={orgHref(orgId, orgKind)}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
{orgName}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={orgHref(orgId, orgKind)}
|
||||||
|
className="ml-auto hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Back to {orgName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
397
src/pages/admin/AdminFeedback.tsx
Normal file
397
src/pages/admin/AdminFeedback.tsx
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN — FEEDBACK TRIAGE
|
||||||
|
|
||||||
|
Reads /api/admin/feedback, writes status and notes back through
|
||||||
|
PATCH, and deletes through DELETE. Deliberately a flat list
|
||||||
|
rather than a table: the message is the content, and messages
|
||||||
|
don't fit in a cell.
|
||||||
|
|
||||||
|
Two capabilities, two ranks. Editors and above can change a
|
||||||
|
status or leave a note; deleting is admin and above, matching
|
||||||
|
requireRole on the server. Both come from the roles ladder
|
||||||
|
rather than an equality check — a superadmin is not role ===
|
||||||
|
"admin", and reading it that way is what hid these controls.
|
||||||
|
|
||||||
|
Every read passes ttl: 0. The api cache exists for public
|
||||||
|
content that changes weekly; a triage queue two people are
|
||||||
|
working at the same time is the opposite case.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { del, get, patch, ApiError } from "../../lib/api.ts";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||||
|
import { feedbackTypeLabel } from "../../data/feedbackTypes.ts";
|
||||||
|
|
||||||
|
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, onRemove, canWrite, canRemove }) {
|
||||||
|
const [note, setNote] = useState(row.admin_note ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<any>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On success this card unmounts, so there's no finally here:
|
||||||
|
// busy only needs clearing on the path where the row survives.
|
||||||
|
async function remove() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await del(`/admin/feedback/${row.id}`);
|
||||||
|
onRemove(row);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
|
||||||
|
setConfirming(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deleting is the irreversible option; marking something
|
||||||
|
spam or archived is the habit this defers to. Hence the
|
||||||
|
second click rather than a window.confirm. */}
|
||||||
|
{canRemove && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-[#26454c]">
|
||||||
|
Delete #{row.id} for good? Marking it spam keeps it recoverable.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={remove}
|
||||||
|
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
|
||||||
|
>
|
||||||
|
{busy ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
|
||||||
|
>
|
||||||
|
Keep it
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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<any[]>([]);
|
||||||
|
const [counts, setCounts] = useState<any>({});
|
||||||
|
const [cursor, setCursor] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<any>(null);
|
||||||
|
|
||||||
|
// Minimums, not equality — see lib/roles.ts.
|
||||||
|
const canWrite = roleCanWrite(user);
|
||||||
|
const canRemove = canDelete(user);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deleted row is passed whole rather than by id: its status
|
||||||
|
// is what says which tab count to drop.
|
||||||
|
function removeRow(removed) {
|
||||||
|
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||||
|
setCounts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: any[] = [
|
||||||
|
{ 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}
|
||||||
|
canRemove={canRemove}
|
||||||
|
onChange={replaceRow}
|
||||||
|
onRemove={removeRow}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/pages/admin/AdminHome.tsx
Normal file
182
src/pages/admin/AdminHome.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN HOME
|
||||||
|
|
||||||
|
Where login lands. Two columns: the cards are the navigation —
|
||||||
|
the header deliberately drops its tab row here so the same links
|
||||||
|
aren't drawn twice — and a standing info panel on the right.
|
||||||
|
|
||||||
|
The cards come from adminNav.ts, the same list the CMS header
|
||||||
|
reads, so a new entity shows up here the moment it's registered.
|
||||||
|
Forms sit in their own block below: they're submissions coming
|
||||||
|
in rather than content going out, and there'll be more of them
|
||||||
|
than the feedback queue eventually. The Panel block below that
|
||||||
|
only exists for superadmins.
|
||||||
|
|
||||||
|
The right-hand panel is deliberately inert. Nothing here
|
||||||
|
fetches, so the landing page can't be slow or half-broken on
|
||||||
|
arrival; anything live (open feedback count, last-edited
|
||||||
|
record) wants to be a separate component that fails on its own.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
|
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.ts";
|
||||||
|
|
||||||
|
/* One card. The title link is stretched over the whole card with
|
||||||
|
`after:absolute`, which makes the card clickable without nesting
|
||||||
|
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
||||||
|
they stay separately clickable. */
|
||||||
|
function NavCard({ item }) {
|
||||||
|
const to = target(item);
|
||||||
|
|
||||||
|
// Drop the child that just repeats the card's own destination —
|
||||||
|
// "All organizations" under Organizations.
|
||||||
|
const extras = (item.children ?? []).filter((child) => child.to !== to);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex flex-col rounded-2xl border border-[#138ba0]/20 bg-white p-5 transition-all hover:border-[#138ba0]/60 hover:shadow-sm">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-[#138ba0]">
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="after:absolute after:inset-0 after:rounded-2xl after:content-['']"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-auto text-[#138ba0] opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.blurb && (
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extras.length > 0 && (
|
||||||
|
<div className="relative z-10 mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-[#138ba0]/10 pt-3 text-sm">
|
||||||
|
{extras.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.to}
|
||||||
|
to={child.to}
|
||||||
|
className="text-[#4a6b72] underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardBlock({ title, blurb, children }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||||
|
{blurb && <p className="text-sm text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelSection({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2.5 text-sm text-[#4a6b72]">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_ADD = [
|
||||||
|
{ to: "/admin/events/new", label: "New event" },
|
||||||
|
{ to: "/admin/people/new", label: "New person" },
|
||||||
|
{ to: "/admin/organizations/new", label: "New organization" },
|
||||||
|
{ to: "/admin/timeline/new", label: "New timeline entry" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminHome() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#138ba0]">
|
||||||
|
{user?.name ? `Welcome back, ${user.name.split(" ")[0]}.` : "Welcome back."}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-[#4a6b72]">Pick a section to work in.</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
{CMS_NAV.map((item) => (
|
||||||
|
<NavCard key={item.label} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBlock title={FORMS_NAV.label} blurb={FORMS_NAV.blurb}>
|
||||||
|
{FORMS_NAV.children.map((form) => (
|
||||||
|
<NavCard key={form.to} item={form} />
|
||||||
|
))}
|
||||||
|
</CardBlock>
|
||||||
|
|
||||||
|
{isSuper(user) && (
|
||||||
|
<CardBlock title="Superadmin" blurb="Only superadmins see this block.">
|
||||||
|
<NavCard item={PANEL_NAV} />
|
||||||
|
</CardBlock>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky so it stays put once the card column outgrows it. */}
|
||||||
|
<aside className="divide-y divide-[#138ba0]/10 rounded-2xl border border-[#138ba0]/20 bg-white lg:sticky lg:top-6">
|
||||||
|
<PanelSection title="Signed in">
|
||||||
|
<p className="font-medium text-[#0f2f36]">{user?.name || user?.email}</p>
|
||||||
|
{user?.name && user?.email && (
|
||||||
|
<p className="mt-0.5 break-all text-xs text-[#4a6b72]/80">{user.email}</p>
|
||||||
|
)}
|
||||||
|
{role && (
|
||||||
|
<span className="mt-2 inline-block rounded-full bg-[#eef9fb] px-2.5 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Start something">
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{QUICK_ADD.map((link) => (
|
||||||
|
<li key={link.to}>
|
||||||
|
<Link
|
||||||
|
to={link.to}
|
||||||
|
className="underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Site">
|
||||||
|
<p className="font-mono text-xs text-[#4a6b72]/80">{SITE_VERSION}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-2 inline-block underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||||
|
>
|
||||||
|
View the public site ↗
|
||||||
|
</a>
|
||||||
|
</PanelSection>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/pages/admin/AdminLayout.tsx
Normal file
180
src/pages/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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.
|
||||||
|
|
||||||
|
Three areas share this chrome: Home, the CMS and the Panel. The
|
||||||
|
wordmark names whichever one you're in, and from anywhere but
|
||||||
|
Home it's the way back to Home.
|
||||||
|
|
||||||
|
The tab row is drawn everywhere except Home, where the card grid
|
||||||
|
is the navigation and drawing both would say the same thing
|
||||||
|
twice. Which tabs appear depends on the signed-in user —
|
||||||
|
navFor() drops the superadmin-only ones — but that's cosmetics.
|
||||||
|
The route guard and the API are what actually say no.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
||||||
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
|
import nguLogo from "../../assets/NGU_Logo.svg";
|
||||||
|
import {
|
||||||
|
ADMIN_HOME,
|
||||||
|
AREA_TITLES,
|
||||||
|
areaFor,
|
||||||
|
matches,
|
||||||
|
navFor,
|
||||||
|
target,
|
||||||
|
} from "./adminNav.ts";
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const area = areaFor(pathname);
|
||||||
|
const areaTitle = AREA_TITLES[area];
|
||||||
|
const isHome = area === "home";
|
||||||
|
|
||||||
|
const nav = useMemo(() => navFor(user), [user]);
|
||||||
|
|
||||||
|
const active = nav.find(
|
||||||
|
(item) =>
|
||||||
|
matches(pathname, item.to) ||
|
||||||
|
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<any>(null);
|
||||||
|
const stableSet = useCallback((value) => setDetail(value), []);
|
||||||
|
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sections only exist in the CMS. Home and Panel already say
|
||||||
|
// what they are in the area title; "Panel | NGU Admin Panel"
|
||||||
|
// would just stutter.
|
||||||
|
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
|
||||||
|
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
|
||||||
|
}, [area, areaTitle, active, activeChild, detail]);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnav = active?.children ?? [];
|
||||||
|
|
||||||
|
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col 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">
|
||||||
|
{/* Already home, so nothing to link to. */}
|
||||||
|
{isHome ? (
|
||||||
|
wordmark
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={ADMIN_HOME}
|
||||||
|
className="rounded transition-opacity hover:opacity-70"
|
||||||
|
title="Back to the admin home"
|
||||||
|
>
|
||||||
|
{wordmark}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isHome && (
|
||||||
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
|
{nav.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>
|
||||||
|
|
||||||
|
{/* flex-1 rather than a fixed height: the footer sits at the
|
||||||
|
bottom of a short page and below the content of a long one,
|
||||||
|
without ever floating over it. */}
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
||||||
|
<AdminTitleContext.Provider value={titleContext}>
|
||||||
|
<Outlet />
|
||||||
|
</AdminTitleContext.Provider>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-[#138ba0]/15 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
|
||||||
|
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
|
||||||
|
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
|
||||||
|
{SITE_VERSION}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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.ts";
|
||||||
|
|
||||||
|
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<any>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/pages/admin/AdminPanel.tsx
Normal file
331
src/pages/admin/AdminPanel.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN PANEL
|
||||||
|
|
||||||
|
Superadmin only, guarded by RequireRole on the route and by
|
||||||
|
requireRole("superadmin") on every endpoint it calls. This page
|
||||||
|
assumes neither: it renders whatever the API gives it and shows
|
||||||
|
whatever the API refuses.
|
||||||
|
|
||||||
|
Three blocks, deliberately boring:
|
||||||
|
|
||||||
|
System — what's actually running, for when something is off
|
||||||
|
Content — row counts, the cheapest "is the database there"
|
||||||
|
Accounts — roles, access and live sessions
|
||||||
|
|
||||||
|
The role select is driven by ROLES from lib/roles.ts, so a new
|
||||||
|
rung on the ladder appears here without this file changing. The
|
||||||
|
legend beside it is the same list — four roles is past the
|
||||||
|
point where "Editor" explains itself.
|
||||||
|
|
||||||
|
Every account write signs that person out, which the server
|
||||||
|
does rather than this page. Two things you can't do here: edit
|
||||||
|
your own row, or take the last active superadmin away. Both are
|
||||||
|
enforced server-side and mirrored in the disabled states, so
|
||||||
|
the reason shows up before the click rather than after it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { del, get, patch } from "../../lib/api.ts";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||||
|
|
||||||
|
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||||
|
marker that says so. Left alone, browsers read it as local time
|
||||||
|
and last-login drifts by the timezone offset. */
|
||||||
|
function when(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptime(seconds) {
|
||||||
|
if (seconds == null) return "—";
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d) return `${d}d ${h}h`;
|
||||||
|
if (h) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({ title, note, children }: any) {
|
||||||
|
return (
|
||||||
|
<section className="mt-8 first:mt-0">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||||
|
{note && <p className="text-sm text-[#4a6b72]">{note}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-words text-sm font-medium text-[#0f2f36]">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<any>(null);
|
||||||
|
|
||||||
|
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||||
|
// the row so a failure on one account doesn't blank the table.
|
||||||
|
const [busyId, setBusyId] = useState<any>(null);
|
||||||
|
const [rowError, setRowError] = useState<any>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setError(err.message || "Couldn't load the panel.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
/* Replace the one row the server returns rather than refetching
|
||||||
|
the whole overview — the counts didn't change. */
|
||||||
|
function mergeUser(updated) {
|
||||||
|
setData((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
users: current.users.map((u) => (u.id === updated.id ? updated : u)),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(id, work) {
|
||||||
|
setBusyId(id);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
mergeUser(await work());
|
||||||
|
} catch (err: any) {
|
||||||
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
setRowError({ id, message: err.message || "That didn't work." });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeRole = (row, role) =>
|
||||||
|
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||||
|
|
||||||
|
const setActive = (row, is_active) =>
|
||||||
|
run(
|
||||||
|
row.id,
|
||||||
|
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||||
|
);
|
||||||
|
|
||||||
|
const revoke = (row) =>
|
||||||
|
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="py-12 text-[#4a6b72]" role="status">
|
||||||
|
Loading the panel…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="py-12">
|
||||||
|
<p className="text-[#b3261e]">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { system, content, users } = data;
|
||||||
|
const activeSupers = users.filter(
|
||||||
|
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#138ba0]">Panel</h1>
|
||||||
|
<p className="mt-1 text-[#4a6b72]">
|
||||||
|
Accounts and server state. Everything here is superadmin-only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Block title="System">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Stat label="Schema version" value={system.schemaVersion} />
|
||||||
|
<Stat label="API uptime" value={uptime(system.uptimeSeconds)} />
|
||||||
|
<Stat label="Started" value={when(system.startedAt)} />
|
||||||
|
<Stat label="Node" value={system.nodeVersion} />
|
||||||
|
<Stat label="Platform" value={system.platform} />
|
||||||
|
<Stat label="Live sessions" value={system.sessions} />
|
||||||
|
</div>
|
||||||
|
{system.dbPath && (
|
||||||
|
<p className="mt-3 font-mono text-xs text-[#4a6b72]/70">{system.dbPath}</p>
|
||||||
|
)}
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block title="Content" note="Row counts, straight from the tables.">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.map((row) => (
|
||||||
|
<span
|
||||||
|
key={row.label}
|
||||||
|
className="rounded-full border border-[#138ba0]/20 bg-white px-3 py-1 text-sm text-[#4a6b72]"
|
||||||
|
>
|
||||||
|
{row.label}{" "}
|
||||||
|
<strong className="font-semibold text-[#0f2f36]">
|
||||||
|
{row.count ?? "—"}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Accounts"
|
||||||
|
note="Changing a role or disabling an account signs that person out."
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
||||||
|
<table className="w-full min-w-[46rem] text-left text-sm">
|
||||||
|
<thead className="border-b border-[#138ba0]/15 text-xs uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">Account</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Last login</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Sessions</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Access</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#138ba0]/10">
|
||||||
|
{users.map((row) => {
|
||||||
|
const isMe = row.id === me?.id;
|
||||||
|
const lastSuper =
|
||||||
|
row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1;
|
||||||
|
const locked = isMe || lastSuper;
|
||||||
|
const busy = busyId === row.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className={row.is_active ? "" : "bg-[#f6fbfc]"}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-[#0f2f36]">
|
||||||
|
{row.name || row.email}
|
||||||
|
{isMe && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#4a6b72]/70">
|
||||||
|
you
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.name && (
|
||||||
|
<div className="text-xs text-[#4a6b72]/80">{row.email}</div>
|
||||||
|
)}
|
||||||
|
{rowError?.id === row.id && (
|
||||||
|
<div className="mt-1 text-xs text-[#b3261e]">
|
||||||
|
{rowError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
disabled={locked || busy}
|
||||||
|
onChange={(e) => changeRole(row, e.target.value)}
|
||||||
|
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||||
|
title={
|
||||||
|
isMe
|
||||||
|
? "You can't change your own role."
|
||||||
|
: lastSuper
|
||||||
|
? "The last active superadmin can't be demoted."
|
||||||
|
: ROLE_NOTES[row.role]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 text-[#4a6b72]">{when(row.last_login_at)}</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[#4a6b72]">{row.sessions}</span>
|
||||||
|
{row.sessions > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => revoke(row)}
|
||||||
|
className="ml-3 text-xs font-medium text-[#138ba0] underline-offset-4 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={locked || busy}
|
||||||
|
onClick={() => setActive(row, row.is_active ? 0 : 1)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
row.is_active
|
||||||
|
? "border-[#4a6b72]/30 text-[#4a6b72] hover:border-[#b3261e]/50 hover:text-[#b3261e]"
|
||||||
|
: "border-[#138ba0]/40 text-[#138ba0] hover:bg-[#eef9fb]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.is_active ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Each rung adds to the one above it. Worth stating, because
|
||||||
|
"Editor" doesn't tell you where the line falls. */}
|
||||||
|
<dl className="mt-4 grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<div key={role} className="flex gap-2">
|
||||||
|
<dt className="shrink-0 font-medium text-[#0f2f36]">
|
||||||
|
{ROLE_LABELS[role]}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-[#4a6b72]">{ROLE_NOTES[role]}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-[#4a6b72]/80">
|
||||||
|
New accounts are still created with <code>admin-cli.js</code> on the server.
|
||||||
|
</p>
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
472
src/pages/admin/EntityEdit.tsx
Normal file
472
src/pages/admin/EntityEdit.tsx
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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.
|
||||||
|
|
||||||
|
Two capabilities, not one role. An editor may create and update
|
||||||
|
but not delete, so the action bar asks canWrite/canDelete rather
|
||||||
|
than comparing user.role to a string. The comparison this
|
||||||
|
replaced — role === "admin" — locked superadmins out of saving
|
||||||
|
the moment a rank above admin existed, which is what an equality
|
||||||
|
test against a ladder always eventually does.
|
||||||
|
|
||||||
|
None of this is protection. The server refuses the request; this
|
||||||
|
only decides whether to draw a button that would be refused.
|
||||||
|
|
||||||
|
slugFrom may name one field or several. Most ids are unique
|
||||||
|
because the name is: two organizations aren't both called
|
||||||
|
Northwest. Team ids are the exception — teams.id is a global
|
||||||
|
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.ts";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||||
|
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.ts";
|
||||||
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||||
|
|
||||||
|
/* A foreign key refusing to budge is the most common way a save or
|
||||||
|
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 = atLeast(user, "editor");
|
||||||
|
const canDelete = atLeast(user, "admin");
|
||||||
|
|
||||||
|
// Some entities have no slug: the table assigns an integer id, so
|
||||||
|
// there is nothing to type on create and nothing to compose from
|
||||||
|
// other fields. Timeline entries are the first — an entry that
|
||||||
|
// references an event has no name of its own.
|
||||||
|
const autoId = manifest?.idKind === "auto";
|
||||||
|
|
||||||
|
// A singleton has one row, made by its migration: no slug to show
|
||||||
|
// or type, no list to go back to, nothing to delete. The server
|
||||||
|
// refuses create and delete regardless; this only stops offering
|
||||||
|
// them.
|
||||||
|
const singleton = Boolean(manifest?.singleton);
|
||||||
|
|
||||||
|
// 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". An entity with no slug
|
||||||
|
// names the field to read instead.
|
||||||
|
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<any>(null);
|
||||||
|
const [options, setOptions] = useState<any>({});
|
||||||
|
const [errors, setErrors] = useState<any>({});
|
||||||
|
const [message, setMessage] = useState<any>(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<any>(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.
|
||||||
|
// No id key for an auto entity: the table assigns it, and
|
||||||
|
// sending "" would be an explicit value rather than an absence.
|
||||||
|
const blank = autoId ? {} : { id: "" };
|
||||||
|
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||||
|
setForm(blank);
|
||||||
|
baseline.current = JSON.stringify(blank);
|
||||||
|
} 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, autoId, 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 = singleton ? manifest.label : getPath(form, headingPath) || form.id;
|
||||||
|
const updatedAt = form.updated_at;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{!singleton && (
|
||||||
|
<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. An auto-id entity has nothing to ask for on create, and
|
||||||
|
nothing editable afterwards — so it gets a plain line rather
|
||||||
|
than a disabled box pretending to be a field. */}
|
||||||
|
{singleton ? (
|
||||||
|
updatedAt && (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<p className="text-sm text-[#4a6b72]">Last saved {updatedAt}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : autoId ? (
|
||||||
|
!isNew && (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<p className="text-sm text-[#4a6b72]">
|
||||||
|
{manifest.idLabel} #{form.id}
|
||||||
|
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<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}
|
||||||
|
row={form}
|
||||||
|
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 && !singleton && canDelete && (
|
||||||
|
<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 view this but not change it.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/pages/admin/EntityList.tsx
Normal file
198
src/pages/admin/EntityList.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
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.
|
||||||
|
|
||||||
|
The "New X" link follows the same rule as EntityEdit's save
|
||||||
|
button: atLeast(user, "editor"), matching requireRole("editor")
|
||||||
|
on POST /api/admin/:entity. Drawing it is not permission — the
|
||||||
|
server decides — it only avoids offering a click that 403s, and
|
||||||
|
avoids hiding one that wouldn't.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { get, ApiError } from "../../lib/api.ts";
|
||||||
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.ts";
|
||||||
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
|
||||||
|
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<any[]>([]);
|
||||||
|
const [options, setOptions] = useState<any>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<any>(null);
|
||||||
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
|
|
||||||
|
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||||
|
// at "editor", so anyone from editor upward may create — and the
|
||||||
|
// equality test this replaced hid the button from superadmins as
|
||||||
|
// well as editors, which is the failure mode an == against a
|
||||||
|
// ladder always produces once a rank is added above it.
|
||||||
|
const canWrite = atLeast(user, "editor");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!manifest || manifest.singleton) 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row, so no list: the tab opens the row.
|
||||||
|
if (manifest.singleton) {
|
||||||
|
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/pages/admin/RequireRole.tsx
Normal file
34
src/pages/admin/RequireRole.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ROLE GUARD — src/pages/admin/RequireRole.tsx
|
||||||
|
|
||||||
|
Sits inside RequireAuth, never instead of it: by the time this
|
||||||
|
renders, the session question has already been answered. All
|
||||||
|
this decides is whether the answer was good enough.
|
||||||
|
|
||||||
|
Same caveat as RequireAuth — this hides the interface, not the
|
||||||
|
data. /api/admin/panel/* is superadmin-only on the server, and
|
||||||
|
that's the part that matters. Without it, a bookmarked URL and
|
||||||
|
a disabled select would be the only thing between a viewer and
|
||||||
|
the account list.
|
||||||
|
|
||||||
|
Bounces to Home rather than showing a "denied" page. Someone
|
||||||
|
who lands here has almost always followed a stale link, and a
|
||||||
|
working page beats an explanation of one.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
import { atLeast, type Role } from "../../lib/roles.ts";
|
||||||
|
import { ADMIN_HOME } from "./adminNav.ts";
|
||||||
|
|
||||||
|
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
// RequireAuth is already showing its own placeholder above this.
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/admin/login" replace />;
|
||||||
|
if (!atLeast(user, role)) return <Navigate to={ADMIN_HOME} replace />;
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
120
src/pages/admin/adminNav.ts
Normal file
120
src/pages/admin/adminNav.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
ADMIN NAVIGATION — src/pages/admin/adminNav.ts
|
||||||
|
|
||||||
|
Lifted out of AdminLayout because two things read it now: the
|
||||||
|
header tabs and the card grid on the home page. Adding an entity
|
||||||
|
stays one entry here plus a descriptor — there's still no second
|
||||||
|
list to keep in step, it just isn't inside the layout file
|
||||||
|
any more.
|
||||||
|
|
||||||
|
`blurb` is only read by the home cards. The header ignores it.
|
||||||
|
|
||||||
|
`superOnly` hides an entry from anyone below superadmin. It
|
||||||
|
hides, nothing more: the route still has to be guarded and the
|
||||||
|
API still has to say no. Treating a filtered menu as access
|
||||||
|
control is how you end up with a URL that works.
|
||||||
|
|
||||||
|
Three areas, three titles. The area is derived from the path
|
||||||
|
rather than declared per route, so a page added under
|
||||||
|
/admin/panel/… inherits the right title without registering
|
||||||
|
anything.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { isSuper } from "../../lib/roles.ts";
|
||||||
|
|
||||||
|
export const ADMIN_HOME = "/admin/home";
|
||||||
|
export const ADMIN_PANEL = "/admin/panel";
|
||||||
|
|
||||||
|
export const AREA_TITLES = {
|
||||||
|
home: "NGU Admin Home",
|
||||||
|
cms: "NGU Admin CMS",
|
||||||
|
panel: "NGU Admin Panel",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The CMS tabs. Teams and Awards live under Organizations because
|
||||||
|
that's where they belong conceptually — a team is part of an org,
|
||||||
|
an award is given by one — even though each is its own table and
|
||||||
|
its own page. */
|
||||||
|
export const CMS_NAV = [
|
||||||
|
{
|
||||||
|
to: "/admin/events",
|
||||||
|
label: "Events",
|
||||||
|
blurb: "Retreats, conferences and gatherings, with their sections and rosters.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/organizations",
|
||||||
|
label: "Organizations",
|
||||||
|
blurb: "Regions, chapters and partners — plus the teams and awards they own.",
|
||||||
|
children: [
|
||||||
|
{ to: "/admin/organizations", label: "All organizations" },
|
||||||
|
{ to: "/admin/teams", label: "Teams" },
|
||||||
|
{ to: "/admin/awards", label: "Awards" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/people",
|
||||||
|
label: "People",
|
||||||
|
blurb: "Bios, contact details, affiliations and awards received.",
|
||||||
|
},
|
||||||
|
// Its own tab rather than a child of anything: a timeline entry can
|
||||||
|
// point at an event, an organization, an award, a person or a team,
|
||||||
|
// so filing it under one of them would be arbitrary.
|
||||||
|
{
|
||||||
|
to: "/admin/timeline",
|
||||||
|
label: "Timeline",
|
||||||
|
blurb: "What the history page shows, and the order it shows it in.",
|
||||||
|
},
|
||||||
|
// One record, not a list — the tab opens it directly.
|
||||||
|
{
|
||||||
|
to: "/admin/front_page",
|
||||||
|
label: "Front page",
|
||||||
|
blurb: "The hero, its photos or livestream, and which bands the home page draws.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Forms are submissions coming in rather than content going out, so
|
||||||
|
they get their own group in the header and their own block on the
|
||||||
|
home page. A group with no `to` of its own opens its first child,
|
||||||
|
so clicking the word Forms goes somewhere rather than nowhere. */
|
||||||
|
export const FORMS_NAV = {
|
||||||
|
label: "Forms",
|
||||||
|
blurb: "Whatever the public site has sent us.",
|
||||||
|
separated: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
to: "/admin/feedback",
|
||||||
|
label: "Website feedback",
|
||||||
|
blurb: "The triage queue for the feedback form.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Accounts and server state, not content — which is why it sits
|
||||||
|
outside the CMS rather than as another tab within it. */
|
||||||
|
export const PANEL_NAV = {
|
||||||
|
to: ADMIN_PANEL,
|
||||||
|
label: "Panel",
|
||||||
|
blurb: "Accounts, sessions and the state of the server.",
|
||||||
|
separated: true,
|
||||||
|
superOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAV: any[] = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
|
||||||
|
|
||||||
|
/* What this user may see. Call it with the user from useAuth. */
|
||||||
|
export function navFor(user) {
|
||||||
|
return NAV.filter((item) => !item.superOnly || isSuper(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tab owns its own page and everything below it, so editing
|
||||||
|
/admin/teams/ngu-board keeps Teams lit. */
|
||||||
|
export const matches = (pathname, to) =>
|
||||||
|
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||||
|
|
||||||
|
export const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||||
|
|
||||||
|
export function areaFor(pathname) {
|
||||||
|
if (matches(pathname, ADMIN_HOME)) return "home";
|
||||||
|
if (matches(pathname, ADMIN_PANEL)) return "panel";
|
||||||
|
return "cms";
|
||||||
|
}
|
||||||
794
src/pages/sections/EventCalendar.tsx
Normal file
794
src/pages/sections/EventCalendar.tsx
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EVENT CALENDAR
|
||||||
|
|
||||||
|
Every published event on a month grid, or as a list of the month.
|
||||||
|
Self contained like EventListCards: give it a filter and it
|
||||||
|
fetches, so it can sit on any page.
|
||||||
|
|
||||||
|
<EventCalendar /> everything
|
||||||
|
<EventCalendar host="northwest" /> one host's calendar
|
||||||
|
<EventCalendar scope="national" /> one scope
|
||||||
|
<EventCalendar type={["class", "workshop"]} controls={["search"]} />
|
||||||
|
|
||||||
|
Two layers of filtering, and they answer different questions:
|
||||||
|
|
||||||
|
props what this page's calendar is about. Pinned; the
|
||||||
|
visitor can't widen them, and the control for a
|
||||||
|
pinned dimension doesn't render.
|
||||||
|
controls what the visitor can narrow by within that: scope,
|
||||||
|
type, online only, search. All four by default,
|
||||||
|
every one starting at "all".
|
||||||
|
|
||||||
|
What lands on a day:
|
||||||
|
|
||||||
|
one-off every day from starts_on to ends_on, drawn as one
|
||||||
|
bar across the days it spans, broken at the week
|
||||||
|
edge and marked as continuing
|
||||||
|
series every meeting the schedule produces in view (see
|
||||||
|
eventSeries.ts), one day each, with its time
|
||||||
|
undated nowhere — there's no day to put it on. Counted
|
||||||
|
under the grid so it doesn't vanish without a word.
|
||||||
|
|
||||||
|
Bars are laid out per week in lanes, so a long event keeps its
|
||||||
|
row across the days it covers. MAX_LANES rows show; a day with
|
||||||
|
more says "+N more", and clicking any day lists everything on it
|
||||||
|
below the grid.
|
||||||
|
|
||||||
|
Below md the grid would be seven unreadable slivers, so the month
|
||||||
|
view shows the list there instead. The toggle still works; it
|
||||||
|
just has one answer on a phone.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { typesPresent, useEvents } from '../../data/eventData.ts'
|
||||||
|
import { EVENT_TYPES, eventTypeLabel, type EventType } from '../../lib/eventTypes.ts'
|
||||||
|
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
|
||||||
|
import { eventHref } from '../../lib/hrefs.ts'
|
||||||
|
import type { EventListItem } from '../../lib/useContent.ts'
|
||||||
|
|
||||||
|
export type CalendarControl = 'scope' | 'type' | 'online' | 'search'
|
||||||
|
export type CalendarView = 'month' | 'list'
|
||||||
|
|
||||||
|
const ALL_CONTROLS: CalendarControl[] = ['scope', 'type', 'online', 'search']
|
||||||
|
|
||||||
|
/* Bar rows drawn per week before a day collapses to "+N more". */
|
||||||
|
const MAX_LANES = 3
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const INK = '#073d4a'
|
||||||
|
const BODY = '#4a6b72'
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
type EventCalendarProps = {
|
||||||
|
scope?: string
|
||||||
|
host?: string
|
||||||
|
status?: EventListItem['status']
|
||||||
|
type?: EventType | EventType[]
|
||||||
|
accent?: string
|
||||||
|
/** Which visitor controls render. A pinned prop hides its own. */
|
||||||
|
controls?: CalendarControl[]
|
||||||
|
defaultView?: CalendarView
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One appearance of an event on the calendar: a whole one-off, or a
|
||||||
|
single meeting of a series. Dates are inclusive 'YYYY-MM-DD'. */
|
||||||
|
type Occurrence = {
|
||||||
|
key: string
|
||||||
|
event: EventListItem
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Segment = Occurrence & {
|
||||||
|
col: number
|
||||||
|
span: number
|
||||||
|
lane: number
|
||||||
|
continuesBefore: boolean
|
||||||
|
continuesAfter: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dates ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
|
||||||
|
function parse(date: string): Date {
|
||||||
|
const [y, m, d] = date.split('-').map(Number)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addDays = (date: string, n: number) => {
|
||||||
|
const d = parse(date)
|
||||||
|
d.setDate(d.getDate() + n)
|
||||||
|
return iso(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayDiff = (a: string, b: string) =>
|
||||||
|
Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000)
|
||||||
|
|
||||||
|
const monthKey = (date: string) => date.slice(0, 7)
|
||||||
|
|
||||||
|
/* The six Sunday-started weeks that cover a month. */
|
||||||
|
function gridFor(month: string): string[][] {
|
||||||
|
const first = parse(`${month}-01`)
|
||||||
|
const start = addDays(iso(first), -first.getDay())
|
||||||
|
return Array.from({ length: 6 }, (_, w) =>
|
||||||
|
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthTitle = (month: string) =>
|
||||||
|
parse(`${month}-01`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
const shiftMonth = (month: string, n: number) => {
|
||||||
|
const d = parse(`${month}-01`)
|
||||||
|
d.setMonth(d.getMonth() + n)
|
||||||
|
return monthKey(iso(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
const longDay = (date: string) =>
|
||||||
|
parse(date).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
function rangeLabel(start: string, end: string): string {
|
||||||
|
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
|
||||||
|
if (start === end) return parse(start).toLocaleDateString(undefined, opts)
|
||||||
|
return `${parse(start).toLocaleDateString(undefined, opts)} – ${parse(end).toLocaleDateString(undefined, opts)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Occurrences ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function occurrencesIn(events: EventListItem[], from: string, to: string): Occurrence[] {
|
||||||
|
const out: Occurrence[] = []
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.starts_on) continue
|
||||||
|
|
||||||
|
if (event.series) {
|
||||||
|
const time = seriesTimes(event.series)
|
||||||
|
for (const date of occurrencesBetween(event.series, event.starts_on, event.ends_on, from, to)) {
|
||||||
|
out.push({ key: `${event.id}@${date}`, event, start: date, end: date, time })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = event.starts_on
|
||||||
|
const end = event.ends_on && event.ends_on >= start ? event.ends_on : start
|
||||||
|
if (end < from || start > to) continue
|
||||||
|
out.push({ key: event.id, event, start, end, time: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest first within a day, so multi-day bars claim the top lanes.
|
||||||
|
return out.sort(
|
||||||
|
(a, b) => a.start.localeCompare(b.start) || dayDiff(b.start, b.end) - dayDiff(a.start, a.end),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Greedy lane packing for one week: each segment takes the first
|
||||||
|
lane whose last occupant ended before it starts. */
|
||||||
|
function layoutWeek(week: string[], occurrences: Occurrence[]): Segment[] {
|
||||||
|
const first = week[0]
|
||||||
|
const last = week[6]
|
||||||
|
const laneEnds: number[] = []
|
||||||
|
const segments: Segment[] = []
|
||||||
|
|
||||||
|
for (const occ of occurrences) {
|
||||||
|
if (occ.end < first || occ.start > last) continue
|
||||||
|
const start = occ.start < first ? first : occ.start
|
||||||
|
const end = occ.end > last ? last : occ.end
|
||||||
|
const col = dayDiff(first, start)
|
||||||
|
const span = dayDiff(start, end) + 1
|
||||||
|
|
||||||
|
let lane = laneEnds.findIndex((endCol) => endCol < col)
|
||||||
|
if (lane === -1) lane = laneEnds.length
|
||||||
|
laneEnds[lane] = col + span - 1
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
...occ,
|
||||||
|
col,
|
||||||
|
span,
|
||||||
|
lane,
|
||||||
|
continuesBefore: occ.start < first,
|
||||||
|
continuesAfter: occ.end > last,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The next date after `after` that any of these events lands on. */
|
||||||
|
function nextDateAfter(events: EventListItem[], after: string): string | null {
|
||||||
|
let best: string | null = null
|
||||||
|
const from = addDays(after, 1)
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.starts_on) continue
|
||||||
|
const date = event.series
|
||||||
|
? firstOccurrenceFrom(event.series, event.starts_on, event.ends_on, from)
|
||||||
|
: event.starts_on >= from
|
||||||
|
? event.starts_on
|
||||||
|
: null
|
||||||
|
if (date && (!best || date < best)) best = date
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Component ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function EventCalendar({
|
||||||
|
scope,
|
||||||
|
host,
|
||||||
|
status,
|
||||||
|
type,
|
||||||
|
accent = TEAL,
|
||||||
|
controls = ALL_CONTROLS,
|
||||||
|
defaultView = 'month',
|
||||||
|
}: EventCalendarProps) {
|
||||||
|
const { events: pinned, scopes, loading, error } = useEvents({ scope, host, status, type })
|
||||||
|
|
||||||
|
const today = iso(new Date())
|
||||||
|
const [month, setMonth] = useState(monthKey(today))
|
||||||
|
const [view, setView] = useState<CalendarView>(defaultView)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [pickedScope, setPickedScope] = useState('')
|
||||||
|
const [kind, setKind] = useState('')
|
||||||
|
const [onlineOnly, setOnlineOnly] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const show = (control: CalendarControl) => controls.includes(control)
|
||||||
|
const showScope = show('scope') && !scope
|
||||||
|
const showType = show('type') && !type
|
||||||
|
|
||||||
|
const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned])
|
||||||
|
const scopeOptions = useMemo(() => {
|
||||||
|
const present = new Set(pinned.map((e) => e.scope_id))
|
||||||
|
return scopes.filter((s) => present.has(s.id))
|
||||||
|
}, [pinned, scopes])
|
||||||
|
|
||||||
|
const events = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
return pinned.filter((e) => {
|
||||||
|
if (pickedScope && e.scope_id !== pickedScope) return false
|
||||||
|
if (kind && e.event_type !== kind) return false
|
||||||
|
if (onlineOnly && !e.is_online) return false
|
||||||
|
if (needle) {
|
||||||
|
const haystack = [
|
||||||
|
e.title,
|
||||||
|
e.theme,
|
||||||
|
e.location_label,
|
||||||
|
e.locality,
|
||||||
|
...(e.hosts ?? []).map((h) => h.name),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
if (!haystack.includes(needle)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [pinned, pickedScope, kind, onlineOnly, query])
|
||||||
|
|
||||||
|
const weeks = useMemo(() => gridFor(month), [month])
|
||||||
|
const gridFrom = weeks[0][0]
|
||||||
|
const gridTo = weeks[5][6]
|
||||||
|
const monthFrom = `${month}-01`
|
||||||
|
const monthTo = addDays(`${shiftMonth(month, 1)}-01`, -1)
|
||||||
|
|
||||||
|
const occurrences = useMemo(
|
||||||
|
() => occurrencesIn(events, gridFrom, gridTo),
|
||||||
|
[events, gridFrom, gridTo],
|
||||||
|
)
|
||||||
|
const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo)
|
||||||
|
const undated = events.filter((e) => !e.starts_on).length
|
||||||
|
const filtering = Boolean(pickedScope || kind || onlineOnly || query)
|
||||||
|
const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
|
||||||
|
|
||||||
|
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
|
||||||
|
|
||||||
|
const go = (target: string) => {
|
||||||
|
setMonth(target)
|
||||||
|
setSelected(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setPickedScope('')
|
||||||
|
setKind('')
|
||||||
|
setOnlineOnly(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-6" style={{ color: INK }}>
|
||||||
|
{/* ── Month navigation and view ── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NavButton label="Previous month" onClick={() => go(shiftMonth(month, -1))} accent={accent}>
|
||||||
|
‹
|
||||||
|
</NavButton>
|
||||||
|
<NavButton label="Next month" onClick={() => go(shiftMonth(month, 1))} accent={accent}>
|
||||||
|
›
|
||||||
|
</NavButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="min-w-[11rem] font-display text-2xl font-bold" aria-live="polite">
|
||||||
|
{monthTitle(month)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{month !== monthKey(today) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(monthKey(today))}
|
||||||
|
className="rounded-full border px-3 py-1 text-sm font-semibold hover:bg-white"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ml-auto hidden overflow-hidden rounded-full border md:flex"
|
||||||
|
style={{ borderColor: accent }}
|
||||||
|
role="group"
|
||||||
|
aria-label="Calendar view"
|
||||||
|
>
|
||||||
|
{(['month', 'list'] as const).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(option)}
|
||||||
|
aria-pressed={view === option}
|
||||||
|
className="px-4 py-1.5 text-sm font-semibold capitalize transition-colors"
|
||||||
|
style={
|
||||||
|
view === option
|
||||||
|
? { background: accent, color: '#ffffff' }
|
||||||
|
: { color: accent }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filters ── */}
|
||||||
|
{(showScope || showType || show('online') || show('search')) && (
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
|
{showScope && scopeOptions.length > 1 && (
|
||||||
|
<FilterSelect
|
||||||
|
label="Scope"
|
||||||
|
value={pickedScope}
|
||||||
|
onChange={setPickedScope}
|
||||||
|
all="All scopes"
|
||||||
|
options={scopeOptions.map((s) => [s.id, s.name])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showType && types.length > 1 && (
|
||||||
|
<FilterSelect
|
||||||
|
label="Type"
|
||||||
|
value={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
all="All types"
|
||||||
|
options={types.map((t) => [t.id, t.plural])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{show('online') && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlineOnly}
|
||||||
|
onChange={(e) => setOnlineOnly(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded"
|
||||||
|
style={{ accentColor: accent }}
|
||||||
|
/>
|
||||||
|
Online only
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{show('search') && (
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search events"
|
||||||
|
aria-label="Search events"
|
||||||
|
className="min-w-[12rem] flex-1 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0] md:max-w-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filtering && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-sm font-semibold hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-2xl bg-white p-6 text-[#b3261e]">
|
||||||
|
Couldn’t load events. {error.message}
|
||||||
|
</p>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="h-[32rem] animate-pulse rounded-3xl bg-white/70" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{view === 'month' && (
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<MonthGrid
|
||||||
|
weeks={weeks}
|
||||||
|
month={month}
|
||||||
|
today={today}
|
||||||
|
occurrences={occurrences}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={(date) => setSelected((s) => (s === date ? null : date))}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
{selected && (
|
||||||
|
<DayPanel
|
||||||
|
date={selected}
|
||||||
|
items={onDay(selected)}
|
||||||
|
accent={accent}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={view === 'month' ? 'md:hidden' : ''}>
|
||||||
|
<MonthList items={inMonth} monthFrom={monthFrom} accent={accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inMonth.length === 0 && (
|
||||||
|
<div className="mt-4 text-center text-sm" style={{ color: BODY }}>
|
||||||
|
{next ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(monthKey(next))}
|
||||||
|
className="font-semibold hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Jump to the next event, {monthTitle(monthKey(next))} →
|
||||||
|
</button>
|
||||||
|
) : filtering ? (
|
||||||
|
'Nothing matches these filters.'
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{undated > 0 && (
|
||||||
|
<p className="mt-4 text-center text-xs" style={{ color: BODY }}>
|
||||||
|
{undated === 1 ? '1 event has' : `${undated} events have`} no dates yet, so{' '}
|
||||||
|
{undated === 1 ? 'isn’t' : 'aren’t'} on the calendar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Month grid ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type MonthGridProps = {
|
||||||
|
weeks: string[][]
|
||||||
|
month: string
|
||||||
|
today: string
|
||||||
|
occurrences: Occurrence[]
|
||||||
|
selected: string | null
|
||||||
|
onSelect: (date: string) => void
|
||||||
|
accent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthGrid({ weeks, month, today, occurrences, selected, onSelect, accent }: MonthGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-3xl border border-[#138ba0]/15 bg-white shadow-sm">
|
||||||
|
<div className="grid grid-cols-7 border-b border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||||
|
{WEEKDAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="px-3 py-2 text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: BODY }}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weeks.map((week) => {
|
||||||
|
const segments = layoutWeek(week, occurrences)
|
||||||
|
const visible = segments.filter((s) => s.lane < MAX_LANES)
|
||||||
|
const hiddenOn = (col: number) =>
|
||||||
|
segments.filter((s) => s.lane >= MAX_LANES && s.col <= col && s.col + s.span > col).length
|
||||||
|
const countOn = (col: number) =>
|
||||||
|
segments.filter((s) => s.col <= col && s.col + s.span > col).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={week[0]} className="relative min-h-[8rem] border-b border-[#138ba0]/10 last:border-b-0">
|
||||||
|
{/* Day cells: the click targets, and the numbers. */}
|
||||||
|
<div className="absolute inset-0 grid grid-cols-7">
|
||||||
|
{week.map((date, col) => {
|
||||||
|
const outside = monthKey(date) !== month
|
||||||
|
const isToday = date === today
|
||||||
|
const isSelected = date === selected
|
||||||
|
const count = countOn(col)
|
||||||
|
const hidden = hiddenOn(col)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(date)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
aria-label={`${longDay(date)}${count ? `, ${count} event${count === 1 ? '' : 's'}` : ''}`}
|
||||||
|
className="relative flex flex-col items-start border-r border-[#138ba0]/10 p-2 text-left transition-colors last:border-r-0 hover:bg-[#f6fbfc]"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? `${accent}14` : outside ? '#fbfdfd' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold"
|
||||||
|
style={
|
||||||
|
isToday
|
||||||
|
? { background: accent, color: '#ffffff' }
|
||||||
|
: { color: outside ? '#b8c6c9' : INK }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Number(date.slice(8))}
|
||||||
|
</span>
|
||||||
|
{hidden > 0 && (
|
||||||
|
<span className="mt-auto text-xs font-semibold" style={{ color: accent }}>
|
||||||
|
+{hidden} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bars, laid over the cells. Only the bars take clicks. */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none relative grid grid-cols-7 gap-y-1 pb-7 pt-10"
|
||||||
|
style={{ gridTemplateRows: `repeat(${MAX_LANES}, 1.5rem)` }}
|
||||||
|
>
|
||||||
|
{visible.map((seg) => (
|
||||||
|
<Bar key={`${seg.key}-${week[0]}`} seg={seg} accent={accent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({ seg, accent }: { seg: Segment; accent: string }) {
|
||||||
|
const color = seg.event.color || accent
|
||||||
|
const cancelled = seg.event.status === 'cancelled'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={eventHref(seg.event.id)}
|
||||||
|
title={`${seg.event.title}${seg.time ? ` · ${seg.time}` : ''}`}
|
||||||
|
className={`pointer-events-auto flex items-center gap-1 truncate px-2 text-xs font-semibold text-white transition-[filter] hover:brightness-110 ${
|
||||||
|
seg.continuesBefore ? 'ml-0 rounded-l-none' : 'ml-1 rounded-l-md'
|
||||||
|
} ${seg.continuesAfter ? 'mr-0 rounded-r-none' : 'mr-1 rounded-r-md'} ${
|
||||||
|
cancelled ? 'line-through opacity-60' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
gridColumn: `${seg.col + 1} / span ${seg.span}`,
|
||||||
|
gridRow: seg.lane + 1,
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seg.continuesBefore && <span aria-hidden="true">←</span>}
|
||||||
|
<span className="truncate">
|
||||||
|
{seg.time && seg.span === 1 && <span className="font-normal opacity-85">{seg.time.split(' – ')[0]} </span>}
|
||||||
|
{seg.event.title}
|
||||||
|
</span>
|
||||||
|
{seg.continuesAfter && (
|
||||||
|
<span className="ml-auto" aria-hidden="true">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The selected day ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function DayPanel({
|
||||||
|
date,
|
||||||
|
items,
|
||||||
|
accent,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
date: string
|
||||||
|
items: Occurrence[]
|
||||||
|
accent: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-3xl border bg-white p-6" style={{ borderColor: `${accent}40` }}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h4 className="font-display text-lg font-bold">{longDay(date)}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto text-sm font-semibold hover:underline"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm" style={{ color: BODY }}>
|
||||||
|
Nothing on this day.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<EventLine item={item} accent={accent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List view ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The month's occurrences by day. An event that began last month
|
||||||
|
files under the 1st, where it's still on. */
|
||||||
|
function MonthList({
|
||||||
|
items,
|
||||||
|
monthFrom,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
items: Occurrence[]
|
||||||
|
monthFrom: string
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-3xl bg-white p-10 text-center" style={{ color: BODY }}>
|
||||||
|
Nothing on the calendar this month.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDay = new Map<string, Occurrence[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const day = item.start < monthFrom ? monthFrom : item.start
|
||||||
|
const list = byDay.get(day)
|
||||||
|
if (list) list.push(item)
|
||||||
|
else byDay.set(day, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{[...byDay.entries()].map(([day, list]) => {
|
||||||
|
const d = parse(day)
|
||||||
|
return (
|
||||||
|
<li key={day} className="flex gap-5 rounded-3xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="w-14 shrink-0 text-center">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: accent }}>
|
||||||
|
{d.toLocaleDateString(undefined, { weekday: 'short' })}
|
||||||
|
</p>
|
||||||
|
<p className="font-display text-3xl font-extrabold leading-none">{d.getDate()}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="min-w-0 flex-1 space-y-3">
|
||||||
|
{list.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<EventLine item={item} accent={accent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One occurrence as a line of text, shared by the day panel and the
|
||||||
|
list so the two describe an event the same way. */
|
||||||
|
function EventLine({ item, accent }: { item: Occurrence; accent: string }) {
|
||||||
|
const { event } = item
|
||||||
|
const color = event.color || accent
|
||||||
|
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||||
|
const cancelled = event.status === 'cancelled'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={eventHref(event.id)}
|
||||||
|
className="group flex items-start gap-3 rounded-xl p-2 transition-colors hover:bg-[#f6fbfc]"
|
||||||
|
>
|
||||||
|
<span className="mt-1.5 h-3 w-3 shrink-0 rounded-full" style={{ background: color }} aria-hidden="true" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className={`block font-semibold group-hover:underline ${cancelled ? 'line-through' : ''}`}>
|
||||||
|
{event.title}
|
||||||
|
{cancelled && <span className="ml-2 text-xs font-bold uppercase text-[#b3261e] no-underline">Cancelled</span>}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm" style={{ color: BODY }}>
|
||||||
|
{[
|
||||||
|
item.time ?? (item.start !== item.end ? rangeLabel(item.start, item.end) : null),
|
||||||
|
eventTypeLabel(event.event_type),
|
||||||
|
where,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function NavButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
accent,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
accent: string
|
||||||
|
children: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full border text-xl transition-colors hover:bg-white"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
all,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
all: string
|
||||||
|
options: Array<[string, string]>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-label={label}
|
||||||
|
className="rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0]"
|
||||||
|
>
|
||||||
|
<option value="">{all}</option>
|
||||||
|
{options.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
import { Link } from "react-router-dom";
|
||||||
|
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.ts";
|
||||||
|
import { eventHref } from "../../lib/hrefs.ts";
|
||||||
|
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||||
|
import { seriesLabel } from "../../lib/eventSeries.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT LIST — CARDS
|
EVENT LIST — CARDS
|
||||||
|
|
||||||
A band of event cards, as a peek carousel or a grid. Self
|
A band of event cards, as a peek carousel or a grid. Self
|
||||||
contained: give it a filter and it fetches, so the same section
|
contained: give it a filter and it fetches, so the same section
|
||||||
appears three times on Retreats with a different `section` each
|
appears three times on Retreats with a different `scope` each
|
||||||
time, and could appear on a region's page with `host` instead.
|
time, and could appear on a region's page with `host` instead.
|
||||||
|
|
||||||
<EventListCards section="national" view="carousel" />
|
<EventListCards scope="national" view="carousel" />
|
||||||
<EventListCards host="northwest" view="grid" />
|
<EventListCards host="northwest" view="grid" />
|
||||||
|
<EventListCards type={["class", "workshop"]} view="grid" />
|
||||||
|
|
||||||
`view` and `accent` come from the page's section manifest.
|
`view` and `accent` come from the page's section manifest.
|
||||||
|
|
||||||
|
`type` pre-filters the band the way `scope` and `host` do. Left
|
||||||
|
off, the band takes every kind it finds and grows a row of chips
|
||||||
|
to narrow by — but only once it holds more than one, so a band of
|
||||||
|
nothing but retreats shows no control at all.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||||
|
|
@ -105,6 +115,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* The title is the way into the event's own page.
|
||||||
|
|
||||||
|
A link on the title rather than a wrapper around the whole card:
|
||||||
|
the footer already holds anchors, and an anchor inside an anchor
|
||||||
|
is invalid markup that every browser resolves by guessing. The
|
||||||
|
carousel has the same constraint — it needs the card's click to
|
||||||
|
mean "bring this one to the front" on anything that isn't the
|
||||||
|
active slide. */
|
||||||
|
function TitleLink({ ev, linked, children }) {
|
||||||
|
if (!linked) return <>{children}</>;
|
||||||
|
return (
|
||||||
|
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT CARD — one component, two sizes.
|
EVENT CARD — one component, two sizes.
|
||||||
compact=false → the full card used in the carousel
|
compact=false → the full card used in the carousel
|
||||||
|
|
@ -119,6 +146,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
Fields arrive pre-resolved from the API — `color` is the event's
|
Fields arrive pre-resolved from the API — `color` is the event's
|
||||||
own or its host's, `status` is derived from the dates when it
|
own or its host's, `status` is derived from the dates when it
|
||||||
isn't set — so nothing here reimplements those rules.
|
isn't set — so nothing here reimplements those rules.
|
||||||
|
|
||||||
|
`linked` is the one thing a caller turns off: a card on the
|
||||||
|
event's own page shouldn't link to the page it's already on.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
export function Card({
|
export function Card({
|
||||||
ev,
|
ev,
|
||||||
|
|
@ -126,16 +156,32 @@ export function Card({
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
compact = false,
|
compact = false,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
|
linked = true,
|
||||||
|
showType = false,
|
||||||
}) {
|
}) {
|
||||||
const past = ev.status === "past";
|
const past = ev.status === "past";
|
||||||
const color = ev.color || defaultColor;
|
const color = ev.color || defaultColor;
|
||||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||||
const eventLogo = ev.event_logo;
|
const eventLogo = ev.event_logo;
|
||||||
|
const schedule = seriesLabel(ev.series, ev.starts_on);
|
||||||
const links = ev.links ?? [];
|
const links = ev.links ?? [];
|
||||||
const igHandle = ev.instagram || null;
|
const igHandle = ev.instagram || null;
|
||||||
const igUrl = igHandle
|
const igUrl = igHandle
|
||||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||||
: null;
|
: undefined;
|
||||||
|
|
||||||
|
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||||||
|
on every card in a row of nothing but retreats is noise, and
|
||||||
|
the card can't tell on its own — it only ever sees one event. */
|
||||||
|
const typeBadge =
|
||||||
|
showType && ev.event_type ? (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
|
||||||
|
style={{ border: `1px solid ${color}`, color }}
|
||||||
|
>
|
||||||
|
{eventTypeLabel(ev.event_type)}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
/* An ordered array, so a card can carry one paragraph or five
|
/* An ordered array, so a card can carry one paragraph or five
|
||||||
without the component changing. */
|
without the component changing. */
|
||||||
|
|
@ -168,11 +214,17 @@ export function Card({
|
||||||
<div className="ev-grid mb-2">
|
<div className="ev-grid mb-2">
|
||||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||||
<div className="ev-info">
|
<div className="ev-info">
|
||||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
{typeBadge}
|
||||||
|
<h3 className="text-3xl font-900 leading-tight">
|
||||||
|
<TitleLink ev={ev} linked={linked}>
|
||||||
|
{ev.title}
|
||||||
|
</TitleLink>
|
||||||
|
</h3>
|
||||||
{ev.theme && (
|
{ev.theme && (
|
||||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||||||
|
{schedule && <p className="text-xl">{schedule}</p>}
|
||||||
{ev.location_label && (
|
{ev.location_label && (
|
||||||
<p className="text-xl">{ev.location_label}</p>
|
<p className="text-xl">{ev.location_label}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -190,11 +242,17 @@ export function Card({
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
{typeBadge}
|
||||||
|
<h3 className="text-4xl font-900">
|
||||||
|
<TitleLink ev={ev} linked={linked}>
|
||||||
|
{ev.title}
|
||||||
|
</TitleLink>
|
||||||
|
</h3>
|
||||||
{ev.theme && (
|
{ev.theme && (
|
||||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||||||
|
{schedule && <p className="text-2xl">{schedule}</p>}
|
||||||
{ev.location_label && (
|
{ev.location_label && (
|
||||||
<p className="text-2xl">{ev.location_label}</p>
|
<p className="text-2xl">{ev.location_label}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -211,10 +269,32 @@ export function Card({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
{/* Footer — mt-auto pins the whole block to the bottom so
|
||||||
across every card in a grid row. */}
|
buttons line up across every card in a grid row.
|
||||||
{links.length > 0 ? (
|
|
||||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
Three registration states, as before: links to follow, a
|
||||||
|
past event, or an announcement still to come. What's new
|
||||||
|
is that all three end in the same row, because every
|
||||||
|
event now has a page and a past one is often the more
|
||||||
|
worth reading — speakers, awards, what actually
|
||||||
|
happened. The notice is what changes; the way in
|
||||||
|
doesn't. */}
|
||||||
|
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||||
|
{links.length === 0 && (
|
||||||
|
<p className="text-center font-600" style={{ color: accent }}>
|
||||||
|
{past
|
||||||
|
? "This event has concluded — thank you to everyone who joined us!"
|
||||||
|
: igHandle
|
||||||
|
? "Registration has not opened yet, follow our instagram for more details."
|
||||||
|
: "Registration has not opened yet — check back soon for more details."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap justify-center items-center ${
|
||||||
|
compact ? "gap-2" : "gap-3"
|
||||||
|
} ${links.length === 0 ? "mt-4" : ""}`}
|
||||||
|
>
|
||||||
{links.map(item => (
|
{links.map(item => (
|
||||||
<a
|
<a
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|
@ -227,28 +307,17 @@ export function Card({
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
) : past ? (
|
{/* Only when there's nothing to register for and the
|
||||||
<p
|
event hasn't happened — the same condition as before,
|
||||||
className="mt-auto text-center font-600 pt-8"
|
just no longer nested inside that branch. */}
|
||||||
style={{ color: accent }}
|
{igHandle && !past && links.length === 0 && (
|
||||||
>
|
|
||||||
This event has concluded — thank you to everyone who joined us!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
|
||||||
<p className="text-center font-600" style={{ color: accent }}>
|
|
||||||
{igHandle
|
|
||||||
? "Registration has not opened yet, follow our instagram for more details."
|
|
||||||
: "Registration has not opened yet — check back soon for more details."}
|
|
||||||
</p>
|
|
||||||
{igHandle && (
|
|
||||||
<a
|
<a
|
||||||
href={igUrl}
|
href={igUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
|
||||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
compact ? "py-2.5 px-5" : "py-3 px-6"
|
||||||
}`}
|
}`}
|
||||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||||
>
|
>
|
||||||
|
|
@ -256,10 +325,67 @@ export function Card({
|
||||||
{igHandle}
|
{igHandle}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Last, so Register reads first when there is one. */}
|
||||||
|
{linked && (
|
||||||
|
<Link
|
||||||
|
to={eventHref(ev.id)}
|
||||||
|
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||||
|
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||||
|
}`}
|
||||||
|
style={{ border: `1px solid ${color}` }}
|
||||||
|
>
|
||||||
|
Event details
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
TYPE FILTER
|
||||||
|
|
||||||
|
Drawn only when a band actually holds more than one kind, so it
|
||||||
|
costs nothing today — every event is a retreat — and appears on
|
||||||
|
its own the first time a class or a workshop lands in that band.
|
||||||
|
Nothing on Retreats.tsx has to be reconfigured for it.
|
||||||
|
|
||||||
|
Chips rather than a select: with four or five options, all of
|
||||||
|
them visible is one tap, and the row reads as what the section
|
||||||
|
contains rather than as a form control.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
export function TypeFilter({ types, active, setActive, accent }) {
|
||||||
|
const chip = on => ({
|
||||||
|
border: `1px solid ${accent}`,
|
||||||
|
background: on ? accent : "transparent",
|
||||||
|
color: on ? "#ffffff" : accent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = (id, label) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
aria-pressed={active === id}
|
||||||
|
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
|
||||||
|
style={chip(active === id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
|
||||||
|
style={{ maxWidth: GRID_MAX }}
|
||||||
|
role="group"
|
||||||
|
aria-label="Filter events by type"
|
||||||
|
>
|
||||||
|
{button("all", "All")}
|
||||||
|
{types.map(entry => button(entry.id, entry.plural))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,25 +446,60 @@ export function EventCardsToggle({ view, setView, accent }) {
|
||||||
than all falling through to "coming soon".
|
than all falling through to "coming soon".
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
export default function EventListCards({
|
export default function EventListCards({
|
||||||
section,
|
scope,
|
||||||
host,
|
host,
|
||||||
status,
|
status,
|
||||||
|
type,
|
||||||
view = "carousel",
|
view = "carousel",
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
defaultColor,
|
defaultColor,
|
||||||
empty = "· Events coming soon, stay connected for announcements ·",
|
empty = "· Events coming soon, stay connected for announcements ·",
|
||||||
}) {
|
}: any) {
|
||||||
const { events, loading, error } = useEvents({ section, host, status });
|
const { events: fetched, loading, error } = useEvents({
|
||||||
|
scope,
|
||||||
|
host,
|
||||||
|
status,
|
||||||
|
type,
|
||||||
|
});
|
||||||
const cardColor = defaultColor ?? accent;
|
const cardColor = defaultColor ?? accent;
|
||||||
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [showPast, setShowPast] = useState(false);
|
const [showPast, setShowPast] = useState(false);
|
||||||
|
const [activeType, setActiveType] = useState("all");
|
||||||
|
|
||||||
|
/* What this band holds, which is what the chips offer — not the
|
||||||
|
full list of declared types, three quarters of which would be
|
||||||
|
dead buttons. */
|
||||||
|
const availableTypes = useMemo(
|
||||||
|
() => typesPresent(fetched, EVENT_TYPES),
|
||||||
|
[fetched],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mixed = availableTypes.length > 1;
|
||||||
|
|
||||||
|
const events = useMemo(
|
||||||
|
() =>
|
||||||
|
activeType === "all"
|
||||||
|
? fetched
|
||||||
|
: fetched.filter(e => e.event_type === activeType),
|
||||||
|
[fetched, activeType],
|
||||||
|
);
|
||||||
|
|
||||||
/* Open on the first upcoming event. The list is empty on the
|
/* Open on the first upcoming event. The list is empty on the
|
||||||
first render, so this can't be a useState initialiser — it has
|
first render, so this can't be a useState initialiser — it has
|
||||||
to wait for the data and then run once. Clearing the guard when
|
to wait for the data and then run once. Clearing the guard when
|
||||||
the list empties means a refetch re-seeds. */
|
the list empties means a refetch re-seeds. */
|
||||||
const seeded = useRef(false);
|
const seeded = useRef(false);
|
||||||
|
|
||||||
|
/* Changing the chip is a different list, so the carousel re-seeds
|
||||||
|
on the first upcoming event of that kind rather than holding an
|
||||||
|
index that may now be past the end. Declared before the seed
|
||||||
|
effect so the guard is already clear when it runs. */
|
||||||
|
useEffect(() => {
|
||||||
|
seeded.current = false;
|
||||||
|
setIndex(0);
|
||||||
|
}, [activeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
seeded.current = false;
|
seeded.current = false;
|
||||||
|
|
@ -395,8 +556,24 @@ export default function EventListCards({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
{mixed && (
|
||||||
|
<TypeFilter
|
||||||
|
types={availableTypes}
|
||||||
|
active={activeType}
|
||||||
|
setActive={setActiveType}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
notice(empty)
|
/* Two different empties. Nothing scheduled is news; nothing
|
||||||
|
of the kind you just picked is a filter you can undo, and
|
||||||
|
the chips are still on screen to undo it with. */
|
||||||
|
notice(
|
||||||
|
activeType === "all"
|
||||||
|
? empty
|
||||||
|
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
|
||||||
|
)
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||||
|
|
@ -412,6 +589,7 @@ export default function EventListCards({
|
||||||
defaultColor={cardColor}
|
defaultColor={cardColor}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
compact
|
compact
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -450,6 +628,7 @@ export default function EventListCards({
|
||||||
accent={accent}
|
accent={accent}
|
||||||
compact
|
compact
|
||||||
interactive={showPast}
|
interactive={showPast}
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,6 +693,7 @@ export default function EventListCards({
|
||||||
defaultColor={cardColor}
|
defaultColor={cardColor}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
interactive={active}
|
interactive={active}
|
||||||
|
showType={mixed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { post, ApiError } from "../../lib/api.js";
|
import { post, ApiError } from "../../lib/api.ts";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.ts";
|
||||||
|
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.ts";
|
||||||
|
|
||||||
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 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
@ -249,7 +217,7 @@ function describeLocation(page, section) {
|
||||||
/* ── The form ────────────────────────────────────────────────── */
|
/* ── The form ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function FeedbackForm() {
|
export default function FeedbackForm() {
|
||||||
const [type, setType] = useState(null);
|
const [type, setType] = useState<any>(null);
|
||||||
const [page, setPage] = useState(SITE_WIDE);
|
const [page, setPage] = useState(SITE_WIDE);
|
||||||
const [section, setSection] = useState(WHOLE_PAGE);
|
const [section, setSection] = useState(WHOLE_PAGE);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
@ -261,8 +229,8 @@ export default function FeedbackForm() {
|
||||||
|
|
||||||
// idle → sending → sent, or back to idle with an error to show.
|
// idle → sending → sent, or back to idle with an error to show.
|
||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState<any>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState({});
|
const [fieldErrors, setFieldErrors] = useState<any>({});
|
||||||
|
|
||||||
const sending = status === "sending";
|
const sending = status === "sending";
|
||||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
initialsFor,
|
initialsFor,
|
||||||
orgPath,
|
orgPath,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "../../data/organizations.js";
|
} from "../../data/organizations.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — CARDS
|
ORGANIZATION LIST — CARDS
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useCommunity } from "../../data/chapters.js";
|
import { useCommunity } from "../../data/chapters.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
import { initialsFor, orgPath } from "../../data/organizations.ts";
|
||||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
import { AREAS, AREA_NAMES } from "../../data/mapGrid.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — MAP
|
ORGANIZATION LIST — MAP
|
||||||
|
|
@ -25,7 +25,7 @@ import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||||
two colors. If you later want true geography, the swap point is
|
two colors. If you later want true geography, the swap point is
|
||||||
<RegionMap> — everything else works off the data.
|
<RegionMap> — everything else works off the data.
|
||||||
|
|
||||||
The tile layout comes from mapGrid.js; who paints what comes
|
The tile layout comes from mapGrid.ts; who paints what comes
|
||||||
from the API. A state and the Canada band are the same shape
|
from the API. A state and the Canada band are the same shape
|
||||||
now, a tile with a span, so the map is one loop.
|
now, a tile with a span, so the map is one loop.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -46,6 +46,10 @@ const RULE = "#cfe3e7";
|
||||||
const INK = "#2c4a50";
|
const INK = "#2c4a50";
|
||||||
const FALLBACK_COLOR = "#4a6b72";
|
const FALLBACK_COLOR = "#4a6b72";
|
||||||
|
|
||||||
|
/* organizations.color is nullable; an SVG fill left undefined paints
|
||||||
|
black, so an uncoloured region takes the same fallback as a card. */
|
||||||
|
const colorOf = (item) => item.color || FALLBACK_COLOR;
|
||||||
|
|
||||||
const US_TITLE = "US Unity Regions";
|
const US_TITLE = "US Unity Regions";
|
||||||
const INTL_TITLE = "International Unity Regions";
|
const INTL_TITLE = "International Unity Regions";
|
||||||
|
|
||||||
|
|
@ -55,7 +59,7 @@ const INTL_TITLE = "International Unity Regions";
|
||||||
vanishing. When organization and person pages arrive this should
|
vanishing. When organization and person pages arrive this should
|
||||||
move to a shared component; small enough to live here until then.
|
move to a shared component; small enough to live here until then.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
function Blocks({ blocks = [], color }) {
|
function Blocks({ blocks = [] as any[], color }) {
|
||||||
if (blocks.length === 0) return null;
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -141,7 +145,7 @@ function Tile({
|
||||||
size,
|
size,
|
||||||
width = size,
|
width = size,
|
||||||
label,
|
label,
|
||||||
slices = [],
|
slices = [] as any[],
|
||||||
count = 0,
|
count = 0,
|
||||||
selected,
|
selected,
|
||||||
hovered,
|
hovered,
|
||||||
|
|
@ -196,7 +200,7 @@ function Tile({
|
||||||
y={top}
|
y={top}
|
||||||
width={width}
|
width={width}
|
||||||
height={h}
|
height={h}
|
||||||
fill={slice.color}
|
fill={colorOf(slice)}
|
||||||
fillOpacity={opacity}
|
fillOpacity={opacity}
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
@ -211,7 +215,7 @@ function Tile({
|
||||||
height={size}
|
height={size}
|
||||||
rx={14}
|
rx={14}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={active ? primary.color : "#ffffff"}
|
stroke={active ? colorOf(primary) : "#ffffff"}
|
||||||
strokeOpacity={active ? 1 : 0.55}
|
strokeOpacity={active ? 1 : 0.55}
|
||||||
strokeWidth={active ? 4 : 2}
|
strokeWidth={active ? 4 : 2}
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200"
|
||||||
|
|
@ -224,7 +228,7 @@ function Tile({
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
fill={count ? "#ffffff" : primary.color}
|
fill={count ? "#ffffff" : colorOf(primary)}
|
||||||
style={{ pointerEvents: "none" }}
|
style={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
{label || code}
|
{label || code}
|
||||||
|
|
@ -244,7 +248,7 @@ function Tile({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
function RegionMap({ slices, chapterCounts, ...props }: any) {
|
||||||
const size = TILE - PAD * 2;
|
const size = TILE - PAD * 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -287,22 +291,22 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||||
aria-pressed={selected === region.id}
|
aria-pressed={selected === region.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${region.color}`,
|
border: `1px solid ${colorOf(region)}`,
|
||||||
background: on ? region.color : "transparent",
|
background: on ? colorOf(region) : "transparent",
|
||||||
color: on ? "#ffffff" : region.color,
|
color: on ? "#ffffff" : colorOf(region),
|
||||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
style={{ background: on ? "#ffffff" : region.color }}
|
style={{ background: on ? "#ffffff" : colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
{region.name}
|
{region.name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
function Legend({ domestic, international, onMapIds, ...props }: any) {
|
||||||
const { selected, setSelected } = props;
|
const { selected, setSelected } = props;
|
||||||
|
|
||||||
// A region is on the map if it paints a tile. West Central used
|
// A region is on the map if it paints a tile. West Central used
|
||||||
|
|
@ -374,11 +378,11 @@ function RegionBlock({
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-3 w-3 rounded-full shrink-0"
|
className="h-3 w-3 rounded-full shrink-0"
|
||||||
style={{ background: region.color }}
|
style={{ background: colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
<h4
|
<h4
|
||||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||||
style={{ color: region.color }}
|
style={{ color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
@ -404,7 +408,7 @@ function RegionBlock({
|
||||||
key={c.id}
|
key={c.id}
|
||||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||||
className="pl-3 flex items-start gap-3"
|
className="pl-3 flex items-start gap-3"
|
||||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
style={{ borderLeft: `2px solid ${colorOf(region)}` }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-700">{c.name}</p>
|
<p className="font-700">{c.name}</p>
|
||||||
|
|
@ -420,7 +424,7 @@ function RegionBlock({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-700 underline"
|
className="font-700 underline"
|
||||||
style={{ color: region.color }}
|
style={{ color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -429,7 +433,7 @@ function RegionBlock({
|
||||||
<a
|
<a
|
||||||
href={`mailto:${c.email}`}
|
href={`mailto:${c.email}`}
|
||||||
className="font-700 underline"
|
className="font-700 underline"
|
||||||
style={{ color: region.color }}
|
style={{ color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -442,7 +446,7 @@ function RegionBlock({
|
||||||
<ArrowLink
|
<ArrowLink
|
||||||
to={orgPath(c)}
|
to={orgPath(c)}
|
||||||
label={`${c.name} — chapter page`}
|
label={`${c.name} — chapter page`}
|
||||||
color={region.color}
|
color={colorOf(region)}
|
||||||
size="h-8 w-8"
|
size="h-8 w-8"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -571,10 +575,10 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl p-6 mb-6"
|
className="rounded-2xl p-6 mb-6"
|
||||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
style={{ border: `2px solid ${colorOf(region)}`, background: `${colorOf(region)}0f` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
<OrgLogo org={chapter} color={colorOf(region)} size="h-20 w-20" />
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||||
|
|
@ -585,13 +589,13 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close details"
|
aria-label="Close details"
|
||||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
<Blocks blocks={chapter.blocks} color={colorOf(region)} />
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||||
|
|
@ -611,9 +615,9 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
{orgPath(chapter) && (
|
{orgPath(chapter) && (
|
||||||
<Link
|
<Link
|
||||||
to={orgPath(chapter)}
|
to={orgPath(chapter)!}
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ background: region.color, color: "#ffffff" }}
|
style={{ background: colorOf(region), color: "#ffffff" }}
|
||||||
>
|
>
|
||||||
Chapter page
|
Chapter page
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -624,7 +628,7 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Visit site
|
Visit site
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -634,7 +638,7 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
<a
|
<a
|
||||||
href={`mailto:${chapter.email}`}
|
href={`mailto:${chapter.email}`}
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Get in touch
|
Get in touch
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -661,9 +665,9 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
|
||||||
<div className="flex items-baseline gap-3 mb-1">
|
<div className="flex items-baseline gap-3 mb-1">
|
||||||
<span
|
<span
|
||||||
className="h-3 w-3 rounded-full shrink-0"
|
className="h-3 w-3 rounded-full shrink-0"
|
||||||
style={{ background: region.color }}
|
style={{ background: colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
<h3 className="text-2xl font-800" style={{ color: colorOf(region) }}>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-sm" style={{ color: MUTED }}>
|
<span className="text-sm" style={{ color: MUTED }}>
|
||||||
|
|
@ -696,7 +700,7 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
|
||||||
<ChapterCard
|
<ChapterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
chapter={c}
|
chapter={c}
|
||||||
color={region.color}
|
color={colorOf(region)}
|
||||||
open={openId === c.id}
|
open={openId === c.id}
|
||||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -765,14 +769,14 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||||
subtextFor,
|
subtextFor,
|
||||||
} = useCommunity();
|
} = useCommunity();
|
||||||
|
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState<any>(null);
|
||||||
const [hovered, setHovered] = useState(null);
|
const [hovered, setHovered] = useState<any>(null);
|
||||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
const [openId, setOpenId] = useState<any>(null); // no card open on arrival
|
||||||
|
|
||||||
// The list scrolls itself to whatever the map or legend points at.
|
// The list scrolls itself to whatever the map or legend points at.
|
||||||
const listRef = useRef(null);
|
const listRef = useRef<any>(null);
|
||||||
const regionRefs = useRef({});
|
const regionRefs = useRef<any>({});
|
||||||
const chapterRefs = useRef({});
|
const chapterRefs = useRef<any>({});
|
||||||
|
|
||||||
const scrollListTo = el => {
|
const scrollListTo = el => {
|
||||||
const box = listRef.current;
|
const box = listRef.current;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
initialsFor,
|
initialsFor,
|
||||||
orgPath,
|
orgPath,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "../../data/organizations.js";
|
} from "../../data/organizations.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — VERTICAL
|
ORGANIZATION LIST — VERTICAL
|
||||||
|
|
@ -47,7 +47,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
||||||
it — a link nested in a button is invalid, and a screen reader
|
it — a link nested in a button is invalid, and a screen reader
|
||||||
announces the whole row as one confused control.
|
announces the whole row as one confused control.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
function RowButton({ as: As = "button", color, children, ...rest }: any) {
|
||||||
return (
|
return (
|
||||||
<As
|
<As
|
||||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
|
|
|
||||||
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
defaultOpenYears,
|
||||||
|
groupTimeline,
|
||||||
|
groupYears,
|
||||||
|
partitionByDate,
|
||||||
|
type DecadeMeta,
|
||||||
|
type SortDirection,
|
||||||
|
type TimelineItem,
|
||||||
|
} from '../../../lib/timeline'
|
||||||
|
import TimelineDecade from './TimelineDecade'
|
||||||
|
import TimelineUpcoming from './TimelineUpcoming'
|
||||||
|
import './timeline.css'
|
||||||
|
|
||||||
|
const PARAM = 'y'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: TimelineItem[]
|
||||||
|
decades: DecadeMeta[]
|
||||||
|
direction?: SortDirection
|
||||||
|
/** Decades ending before this year get the pre-program treatment. */
|
||||||
|
programStartYear?: number
|
||||||
|
fillGaps?: boolean
|
||||||
|
/** Injectable for tests and for pinning a date in a screenshot. */
|
||||||
|
now?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryTimeline({
|
||||||
|
items,
|
||||||
|
decades,
|
||||||
|
direction = 'desc',
|
||||||
|
programStartYear = 2002,
|
||||||
|
fillGaps = true,
|
||||||
|
now,
|
||||||
|
}: Props) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
// Upcoming vs recorded is a function of the clock, so an event crosses
|
||||||
|
// over on its own date with nothing to flip.
|
||||||
|
const { upcoming, past } = useMemo(
|
||||||
|
() => partitionByDate(items, now ?? new Date()),
|
||||||
|
[items, now],
|
||||||
|
)
|
||||||
|
|
||||||
|
const upcomingYears = useMemo(
|
||||||
|
() => groupYears(upcoming, { direction }),
|
||||||
|
[upcoming, direction],
|
||||||
|
)
|
||||||
|
|
||||||
|
const grouped = useMemo(
|
||||||
|
() => groupTimeline(past, decades, { direction, programStartYear }),
|
||||||
|
[past, decades, direction, programStartYear],
|
||||||
|
)
|
||||||
|
|
||||||
|
// `?y=2014,2022` deep-links straight to open years. Absence of the
|
||||||
|
// param means "not chosen yet", so fall back to the most recent
|
||||||
|
// featured year; an empty param means everything was collapsed on
|
||||||
|
// purpose.
|
||||||
|
const openYears = useMemo(() => {
|
||||||
|
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
|
||||||
|
const raw = searchParams.get(PARAM) ?? ''
|
||||||
|
return new Set(
|
||||||
|
raw
|
||||||
|
.split(',')
|
||||||
|
.map((part) => Number(part.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n)),
|
||||||
|
)
|
||||||
|
}, [searchParams, grouped])
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(next: Set<number>) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
|
||||||
|
// replace: toggling shouldn't fill the back button with history.
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleYear = useCallback(
|
||||||
|
(year: number) => {
|
||||||
|
const next = new Set(openYears)
|
||||||
|
if (next.has(year)) next.delete(year)
|
||||||
|
else next.add(year)
|
||||||
|
commit(next)
|
||||||
|
},
|
||||||
|
[openYears, commit],
|
||||||
|
)
|
||||||
|
|
||||||
|
const allYears = useMemo(() => {
|
||||||
|
const years = grouped.flatMap((decade) =>
|
||||||
|
decade.years.filter((y) => y.count > 0).map((y) => y.year),
|
||||||
|
)
|
||||||
|
return [...years, ...upcomingYears.map((y) => y.year)]
|
||||||
|
}, [grouped, upcomingYears])
|
||||||
|
|
||||||
|
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
|
||||||
|
|
||||||
|
if (grouped.length === 0 && upcomingYears.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="ngu-tl">
|
||||||
|
<p className="ngu-tl__emptyState">
|
||||||
|
The timeline is empty. Add a milestone to start the record.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ngu-tl">
|
||||||
|
<div className="ngu-tl__toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__toolbarBtn"
|
||||||
|
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
|
||||||
|
>
|
||||||
|
{allOpen ? 'Collapse all years' : 'Expand all years'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimelineUpcoming
|
||||||
|
years={upcomingYears}
|
||||||
|
count={upcoming.length}
|
||||||
|
direction={direction}
|
||||||
|
openYears={openYears}
|
||||||
|
onToggleYear={toggleYear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{grouped.map((decade) => (
|
||||||
|
<TimelineDecade
|
||||||
|
key={decade.decade}
|
||||||
|
decade={decade}
|
||||||
|
openYears={openYears}
|
||||||
|
onToggleYear={toggleYear}
|
||||||
|
direction={direction}
|
||||||
|
fillGaps={fillGaps}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
decadeLabel,
|
||||||
|
withGapYears,
|
||||||
|
type GroupedDecade,
|
||||||
|
type SortDirection,
|
||||||
|
} from '../../../lib/timeline'
|
||||||
|
import TimelineYear from './TimelineYear'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
decade: GroupedDecade
|
||||||
|
openYears: Set<number>
|
||||||
|
onToggleYear: (year: number) => void
|
||||||
|
direction?: SortDirection
|
||||||
|
/** Show years with no records as muted nodes, so gaps stay visible. */
|
||||||
|
fillGaps?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineDecade({
|
||||||
|
decade,
|
||||||
|
openYears,
|
||||||
|
onToggleYear,
|
||||||
|
direction = 'desc',
|
||||||
|
fillGaps = true,
|
||||||
|
}: Props) {
|
||||||
|
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
|
||||||
|
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
|
||||||
|
>
|
||||||
|
<header className="ngu-tl__decadeHead">
|
||||||
|
<span className="ngu-tl__decadeNum" aria-hidden="true">
|
||||||
|
{decadeLabel(decade.decade)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
|
||||||
|
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
|
||||||
|
{decade.title}
|
||||||
|
</h3>
|
||||||
|
{decade.tagline ? (
|
||||||
|
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
|
||||||
|
) : null}
|
||||||
|
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
|
||||||
|
{decade.preProgram ? (
|
||||||
|
<p className="ngu-tl__gapNote">
|
||||||
|
<span aria-hidden="true">◌</span>
|
||||||
|
Before the program — kept for context, not part of our record
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{years.map((year) => (
|
||||||
|
<TimelineYear
|
||||||
|
key={year.year}
|
||||||
|
year={year}
|
||||||
|
open={openYears.has(year.year)}
|
||||||
|
onToggle={onToggleYear}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
|
||||||
|
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
|
||||||
|
|
||||||
|
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
|
||||||
|
milestone: 'Milestone',
|
||||||
|
event: 'Event',
|
||||||
|
organization: 'Organization',
|
||||||
|
award: 'Award',
|
||||||
|
people: 'People',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day number only — the month node above already carries the month. */
|
||||||
|
function dayMarker(item: TimelineItem): string {
|
||||||
|
if (item.precision !== 'day') return ''
|
||||||
|
const day = Number(item.date.split('-')[2])
|
||||||
|
return Number.isFinite(day) ? String(day) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Featured entries sit above the month nodes, so they spell out their date. */
|
||||||
|
function fullMarker(item: TimelineItem): string {
|
||||||
|
const [, m, d] = item.date.split('-')
|
||||||
|
if (item.precision === 'year' || !m) return 'Date unrecorded'
|
||||||
|
const month = MONTH_LABELS[Number(m) - 1] ?? ''
|
||||||
|
if (item.precision === 'month' || !d) return month
|
||||||
|
return `${month} ${Number(d)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initials stand in when a person has no photo on file. */
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonChip({ person }: { person: PersonRef }) {
|
||||||
|
const src = photoSrc(person.photo)
|
||||||
|
return (
|
||||||
|
<li className="ngu-tl__person">
|
||||||
|
{src ? (
|
||||||
|
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
|
||||||
|
{initials(person.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ngu-tl__personName">
|
||||||
|
{person.name}
|
||||||
|
{person.title ? (
|
||||||
|
<span className="ngu-tl__personTitle">{person.title}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: TimelineItem
|
||||||
|
/** Featured entries render larger and lead the year. */
|
||||||
|
variant?: 'inline' | 'featured'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
|
||||||
|
const featured = variant === 'featured'
|
||||||
|
const href = hrefFor(item)
|
||||||
|
const logo = logoSrc(item)
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
'ngu-tl__entry',
|
||||||
|
featured ? 'ngu-tl__entry--featured' : '',
|
||||||
|
href ? 'ngu-tl__entry--linked' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const marker = (
|
||||||
|
<span className="ngu-tl__entryDay">
|
||||||
|
{featured ? fullMarker(item) : dayMarker(item)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<span className="ngu-tl__entryBody">
|
||||||
|
<span className="ngu-tl__entryTitle">
|
||||||
|
{logo ? (
|
||||||
|
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
|
||||||
|
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
// The people list sits outside the link: each name is its own
|
||||||
|
// destination, and nesting anchors is invalid markup anyway.
|
||||||
|
const roster =
|
||||||
|
item.kind === 'people' && item.people && item.people.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
|
||||||
|
>
|
||||||
|
{item.team ? (
|
||||||
|
<span className="ngu-tl__rosterTeam">
|
||||||
|
{item.team.name}
|
||||||
|
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<ul className="ngu-tl__people">
|
||||||
|
{item.people.map((person) => (
|
||||||
|
<PersonChip key={person.id} person={person} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
|
||||||
|
// An explicit href may point off-site; Link is for in-app routes only.
|
||||||
|
const external = !!href && /^(https?:)?\/\//.test(href)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="ngu-tl__entryWrap">
|
||||||
|
{href && external ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
aria-label={label}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
) : href ? (
|
||||||
|
<Link to={href} className={className} aria-label={label}>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={className}>
|
||||||
|
{marker}
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{roster}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
|
||||||
|
import TimelineYear from './TimelineYear'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
years: GroupedYear[]
|
||||||
|
count: number
|
||||||
|
direction?: SortDirection
|
||||||
|
openYears: Set<number>
|
||||||
|
onToggleYear: (year: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled but not yet happened. Sits above the most recent decade and
|
||||||
|
* opens upward: the toggle is first in the DOM and the list is rendered
|
||||||
|
* after it, with `flex-direction: column-reverse` flipping the visual
|
||||||
|
* order. That keeps the button anchored next to the decade marker
|
||||||
|
* instead of drifting up the page as items appear.
|
||||||
|
*
|
||||||
|
* Membership is decided by date, not by a flag — see partitionByDate.
|
||||||
|
* An event moves into the record on its own, with nothing to update.
|
||||||
|
*/
|
||||||
|
export default function TimelineUpcoming({
|
||||||
|
years,
|
||||||
|
count,
|
||||||
|
direction = 'desc',
|
||||||
|
openYears,
|
||||||
|
onToggleYear,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
|
||||||
|
aria-label="Upcoming"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__upcomingBtn"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
|
||||||
|
<span className="ngu-tl__upcomingLabel">
|
||||||
|
{open
|
||||||
|
? 'Hide what’s scheduled'
|
||||||
|
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
|
||||||
|
</span>
|
||||||
|
<span className="ngu-tl__chevron" aria-hidden="true">
|
||||||
|
{open ? '▾' : '▴'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="ngu-tl__upcomingList">
|
||||||
|
{years.map((year) => (
|
||||||
|
<TimelineYear
|
||||||
|
key={year.year}
|
||||||
|
year={year}
|
||||||
|
open={openYears.has(year.year)}
|
||||||
|
onToggle={onToggleYear}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/pages/sections/history/TimelineYear.tsx
Normal file
90
src/pages/sections/history/TimelineYear.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { GroupedYear } from '../../../lib/timeline'
|
||||||
|
import TimelineEntry from './TimelineEntry'
|
||||||
|
|
||||||
|
/** Dot diameter scales with volume, so the rail reads as a density chart. */
|
||||||
|
function dotSize(count: number): string {
|
||||||
|
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
year: GroupedYear
|
||||||
|
open: boolean
|
||||||
|
onToggle: (year: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineYear({ year, open, onToggle }: Props) {
|
||||||
|
const empty = year.count === 0
|
||||||
|
const panelId = `ngu-tl-year-${year.year}`
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
'ngu-tl__year',
|
||||||
|
year.featured ? 'ngu-tl__year--featured' : '',
|
||||||
|
open ? 'ngu-tl__year--open' : '',
|
||||||
|
empty ? 'ngu-tl__year--empty' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ngu-tl__yearBtn"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => onToggle(year.year)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="ngu-tl__dot"
|
||||||
|
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="ngu-tl__yearLabel">{year.year}</span>
|
||||||
|
<span className="ngu-tl__yearCount">
|
||||||
|
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
empty ? (
|
||||||
|
<div className="ngu-tl__empty" id={panelId}>
|
||||||
|
Nothing recorded for {year.year} yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ngu-tl__panel" id={panelId}>
|
||||||
|
{year.featuredItems.length > 0 ? (
|
||||||
|
<ul className="ngu-tl__featured ngu-tl__list">
|
||||||
|
{year.featuredItems.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} variant="featured" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{year.months.map((month) => (
|
||||||
|
<section className="ngu-tl__month" key={month.month}>
|
||||||
|
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
|
||||||
|
<ul className="ngu-tl__list">
|
||||||
|
{month.items.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{year.undated.length > 0 ? (
|
||||||
|
<section className="ngu-tl__month ngu-tl__undated">
|
||||||
|
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
|
||||||
|
<ul className="ngu-tl__list">
|
||||||
|
{year.undated.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
663
src/pages/sections/history/timeline.css
Normal file
663
src/pages/sections/history/timeline.css
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
/**
|
||||||
|
* History timeline — scoped under .ngu-tl.
|
||||||
|
*
|
||||||
|
* Everything themeable is a custom property with a fallback, so rewiring to the
|
||||||
|
* site tokens is a one-block edit at the top. Typefaces are deliberately not
|
||||||
|
* set: the timeline inherits the page's type and only controls scale, weight
|
||||||
|
* and tracking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ngu-tl {
|
||||||
|
/* Defaults match the site palette; History.tsx overrides accent and
|
||||||
|
surface per section so the rail sits on the right background. */
|
||||||
|
--tl-ink: #16343a;
|
||||||
|
--tl-muted: #4a6b72;
|
||||||
|
--tl-faint: #8aa7ad;
|
||||||
|
--tl-rail: #cfe4e8;
|
||||||
|
--tl-surface: #ffffff;
|
||||||
|
--tl-accent: #138ba0;
|
||||||
|
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
|
||||||
|
|
||||||
|
--tl-gutter: 5rem;
|
||||||
|
--tl-rail-x: 1.75rem;
|
||||||
|
--tl-rail-w: 2px;
|
||||||
|
|
||||||
|
color: var(--tl-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ngu-tl {
|
||||||
|
--tl-gutter: 3rem;
|
||||||
|
--tl-rail-x: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Decade block ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__decade {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rail. One per decade block so the pre-program run can change style
|
||||||
|
without breaking the line's continuity. */
|
||||||
|
.ngu-tl__decade::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tl-rail-x);
|
||||||
|
width: var(--tl-rail-w);
|
||||||
|
background: var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre::before {
|
||||||
|
background: none;
|
||||||
|
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Last decade fades out rather than stopping dead. */
|
||||||
|
.ngu-tl__decade:last-child::before {
|
||||||
|
bottom: 2rem;
|
||||||
|
mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre {
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Decade header ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__decadeHead {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
padding-block: 3.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
|
||||||
|
padding-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numeral breaks the rail. */
|
||||||
|
.ngu-tl__decadeNum {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
|
||||||
|
padding-block: 0.35rem;
|
||||||
|
background: var(--tl-surface);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.1;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: sideways;
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre .ngu-tl__decadeNum {
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeTitle {
|
||||||
|
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.018em;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 22ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeTagline {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 54ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decadeBlurb {
|
||||||
|
margin: 0.875rem 0 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__gapNote {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px dashed var(--tl-rail);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Year node ———————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__year {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 0.6875rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
|
||||||
|
scrolling the page gives you the organization's density at a glance. */
|
||||||
|
.ngu-tl__dot {
|
||||||
|
position: relative;
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
|
||||||
|
width: var(--tl-dot, 8px);
|
||||||
|
height: var(--tl-dot, 8px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tl-rail);
|
||||||
|
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||||
|
transition: background-color 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearBtn:hover .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--featured .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--featured .ngu-tl__dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -5px;
|
||||||
|
border: 1.5px solid var(--tl-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--open .ngu-tl__dot {
|
||||||
|
background: var(--tl-accent);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__decade--pre .ngu-tl__dot {
|
||||||
|
background: var(--tl-surface);
|
||||||
|
border: 1.5px solid var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearLabel {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 550;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition: color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__yearCount {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__year--empty .ngu-tl__yearLabel {
|
||||||
|
color: var(--tl-faint);
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Expanded panel ——————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__panel {
|
||||||
|
padding: 0.25rem 0 1.5rem var(--tl-gutter);
|
||||||
|
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ngu-tl-reveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__month {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__month:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__monthLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__monthLabel::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--tl-rail);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__undated .ngu-tl__monthLabel {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Entry ————————————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:hover {
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryDay {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
padding-top: 0.1875rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryMeta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryBlurb {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
|
||||||
|
calm and the type still carries the hierarchy. */
|
||||||
|
.ngu-tl__kind {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: 0.0625rem;
|
||||||
|
background: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--milestone {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--event {
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--award {
|
||||||
|
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--person {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Featured entries ————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__featured {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-left: 2px solid var(--tl-accent);
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__entryTitle {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.008em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__entryDay {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ngu-tl__entry--featured:hover {
|
||||||
|
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Empty state ——————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__empty {
|
||||||
|
padding: 0.5rem 0 1.25rem var(--tl-gutter);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ngu-tl *,
|
||||||
|
.ngu-tl *::before,
|
||||||
|
.ngu-tl *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Toolbar + utilities ————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid var(--tl-rail);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn:hover {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
border-color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__toolbarBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__emptyState {
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Upcoming block ——————————————————————————————————————————
|
||||||
|
Opens upward: the toggle is first in the DOM, column-reverse puts the
|
||||||
|
list above it. Faded because none of it has happened yet. */
|
||||||
|
|
||||||
|
.ngu-tl__upcoming {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcoming::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--tl-rail-x);
|
||||||
|
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--tl-muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn:hover {
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingBtn:focus-visible {
|
||||||
|
outline: 2px solid var(--tl-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingDot {
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px dashed var(--tl-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingLabel {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__chevron {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything above the toggle is provisional, and reads that way. */
|
||||||
|
.ngu-tl__upcomingList {
|
||||||
|
opacity: 0.62;
|
||||||
|
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingList:hover,
|
||||||
|
.ngu-tl__upcomingList:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__upcomingList .ngu-tl__dot {
|
||||||
|
background: var(--tl-surface);
|
||||||
|
border: 1.5px dashed var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ngu-tl-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Event / org / award logo ————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__logo {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: -0.1875rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entry--featured .ngu-tl__logo {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
vertical-align: -0.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— People roster ——————————————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__entryWrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__roster {
|
||||||
|
padding: 0.125rem 0 0.5rem 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__roster--featured {
|
||||||
|
padding: 0.5rem 1.125rem 1rem;
|
||||||
|
border-left: 2px solid var(--tl-accent);
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
border-radius: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__rosterTeam {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__people {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem 1rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personPhoto {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--tl-accent-soft);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personPhoto--blank {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--tl-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personName {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__personTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Kind markers, continued ————————————————————————————————— */
|
||||||
|
|
||||||
|
.ngu-tl__kind--organization {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__kind--people {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ngu-tl__entryBody {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
28
src/pages/sections/home/CalendarBand.tsx
Normal file
28
src/pages/sections/home/CalendarBand.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
CALENDAR BAND
|
||||||
|
|
||||||
|
EventCalendar on the front page: every scope and every type, all
|
||||||
|
four visitor filters, starting on this month. The same component
|
||||||
|
can go on any page with a narrower filter — a region's page would
|
||||||
|
pass host, a classes page would pass type — and this file only
|
||||||
|
adds the front page's heading around it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import EventCalendar from '../EventCalendar.tsx'
|
||||||
|
|
||||||
|
type CalendarBandProps = { id: string; title: string; blurb?: string | null }
|
||||||
|
|
||||||
|
export default function CalendarBand({ id, title, blurb }: CalendarBandProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="py-24" style={{ background: '#f6fbfc' }}>
|
||||||
|
<div className="mx-auto mb-10 max-w-6xl px-6">
|
||||||
|
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventCalendar />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FEATURED TIMELINE RAIL
|
||||||
|
|
||||||
|
The history page's featured entries, sideways: oldest on the
|
||||||
|
left, so scrolling right moves forward through time. A line runs
|
||||||
|
under the cards with a dot per entry, and a year label wherever
|
||||||
|
the year changes. The last card goes to the full history.
|
||||||
|
|
||||||
|
Featured is the admin's call — timeline_entries.is_featured,
|
||||||
|
"shown large" on the history page. Nothing else is filtered
|
||||||
|
here; /history has already decided what's public.
|
||||||
|
|
||||||
|
Scrolling is native — touch, trackpad, shift-wheel — with
|
||||||
|
scroll-snap so it settles on a card. A mouse can also drag it, and
|
||||||
|
the arrow buttons step one card. A drag that moved more than a few
|
||||||
|
pixels swallows the click it ends in, so letting go over a card
|
||||||
|
doesn't open it.
|
||||||
|
|
||||||
|
Nothing featured, and the section doesn't render at all.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import HomeLink from './HomeLink.tsx'
|
||||||
|
import { useHistory } from '../../../lib/useHistory.ts'
|
||||||
|
import { hrefFor, logoSrc } from '../../../lib/timelineRefs.ts'
|
||||||
|
import { MONTH_LABELS, type TimelineItem } from '../../../lib/timeline.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const DRAG_SLOP = 6
|
||||||
|
|
||||||
|
type RailProps = { id: string; title: string; blurb?: string | null }
|
||||||
|
|
||||||
|
export default function FeaturedTimelineRail({ id, title, blurb }: RailProps) {
|
||||||
|
const { items, loading, error, reload } = useHistory()
|
||||||
|
const featured = items
|
||||||
|
.filter((item) => item.featured)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
const railRef = useRef<HTMLOListElement>(null)
|
||||||
|
const drag = useRef({ x: 0, left: 0, moved: false, active: false })
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
|
if (!loading && !error && featured.length === 0) return null
|
||||||
|
|
||||||
|
const step = (direction: number) => {
|
||||||
|
const rail = railRef.current
|
||||||
|
const card = rail?.querySelector('li')
|
||||||
|
if (!rail || !card) return
|
||||||
|
rail.scrollBy({ left: direction * (card.clientWidth + 24), behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerDown = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||||
|
if (e.pointerType !== 'mouse' || !railRef.current) return
|
||||||
|
drag.current = { x: e.clientX, left: railRef.current.scrollLeft, moved: false, active: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||||
|
const d = drag.current
|
||||||
|
if (!d.active || !railRef.current) return
|
||||||
|
const dx = e.clientX - d.x
|
||||||
|
if (!d.moved && Math.abs(dx) > DRAG_SLOP) {
|
||||||
|
d.moved = true
|
||||||
|
setDragging(true)
|
||||||
|
railRef.current.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
if (d.moved) railRef.current.scrollLeft = d.left - dx
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
drag.current.active = false
|
||||||
|
setDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="overflow-hidden py-24" style={{ background: '#f4faf7' }}>
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{featured.length > 1 && (
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
{[
|
||||||
|
[-1, '‹', 'Earlier'],
|
||||||
|
[1, '›', 'Later'],
|
||||||
|
].map(([direction, glyph, label]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(direction as number)}
|
||||||
|
aria-label={label as string}
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full border text-2xl transition-colors hover:bg-white"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mx-auto mt-10 max-w-6xl px-6 text-[#b3261e]">
|
||||||
|
Couldn’t load the timeline. {error}{' '}
|
||||||
|
<button type="button" onClick={reload} className="underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="mx-auto mt-12 flex max-w-6xl gap-6 px-6" aria-hidden="true">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-72 w-80 shrink-0 animate-pulse rounded-3xl bg-white" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol
|
||||||
|
ref={railRef}
|
||||||
|
className={`hp-rail mt-12 flex gap-6 overflow-x-auto px-6 pb-4 md:px-[max(1.5rem,calc((100vw-72rem)/2+1.5rem))] ${
|
||||||
|
dragging ? 'hp-rail--dragging' : ''
|
||||||
|
}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
// Links and images are natively draggable, and a native
|
||||||
|
// drag cancels the pointer stream this relies on.
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
if (drag.current.moved) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
drag.current.moved = false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{featured.map((item, index) => (
|
||||||
|
<li key={item.id} className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||||
|
<RailCard
|
||||||
|
item={item}
|
||||||
|
showYear={index === 0 || year(item) !== year(featured[index - 1])}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="h-12" />
|
||||||
|
<Link
|
||||||
|
to="/history"
|
||||||
|
draggable={false}
|
||||||
|
className="flex flex-1 flex-col justify-center rounded-3xl p-8 text-white transition-transform duration-300 hover:-translate-y-1"
|
||||||
|
style={{ background: `linear-gradient(150deg, ${TEAL}, #073d4a)` }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-2xl font-extrabold">The whole story</span>
|
||||||
|
<span className="mt-2 text-white/75">Every year, every milestone.</span>
|
||||||
|
<span className="mt-6 font-semibold">See the full history →</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = (item: TimelineItem) => item.date.slice(0, 4)
|
||||||
|
|
||||||
|
/* "June 2014", "2014", "12 June 2014" — only as much as precision
|
||||||
|
says is true. */
|
||||||
|
function dateLabel(item: TimelineItem): string {
|
||||||
|
const [y, m, d] = item.date.split('-')
|
||||||
|
const month = m ? MONTH_LABELS[Number(m) - 1] : null
|
||||||
|
if (item.precision === 'day' && d && month) return `${Number(d)} ${month} ${y}`
|
||||||
|
if (item.precision !== 'year' && month) return `${month} ${y}`
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailCard({ item, showYear }: { item: TimelineItem; showYear: boolean }) {
|
||||||
|
const href = hrefFor(item)
|
||||||
|
const logo = logoSrc(item)
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{logo && (
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
className="mb-5 h-14 w-14 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.2em]" style={{ color: TEAL }}>
|
||||||
|
{dateLabel(item)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-display text-xl font-bold leading-snug text-[#073d4a]">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
{item.meta && <p className="mt-1 text-sm text-[#4a6b72]">{item.meta}</p>}
|
||||||
|
{item.blurb && (
|
||||||
|
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||||
|
)}
|
||||||
|
{href && (
|
||||||
|
<span className="mt-auto pt-5 text-sm font-semibold" style={{ color: TEAL }}>
|
||||||
|
Read more →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const card =
|
||||||
|
'flex flex-1 flex-col rounded-3xl border border-[#138ba0]/15 bg-white p-7 shadow-sm'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* The line, its dot, and the year where it changes. */}
|
||||||
|
<div className="relative mb-4 h-8">
|
||||||
|
<div className="absolute inset-x-[-1.5rem] top-1/2 h-px bg-[#138ba0]/30" />
|
||||||
|
<span
|
||||||
|
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 bg-white"
|
||||||
|
style={{ borderColor: TEAL }}
|
||||||
|
/>
|
||||||
|
{showYear && (
|
||||||
|
<span
|
||||||
|
className="absolute left-5 top-1/2 -translate-y-1/2 rounded-full px-3 py-0.5 font-display text-sm font-bold text-white"
|
||||||
|
style={{ background: TEAL }}
|
||||||
|
>
|
||||||
|
{year(item)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{href ? (
|
||||||
|
<HomeLink
|
||||||
|
url={href}
|
||||||
|
className={`${card} transition-all duration-300 hover:-translate-y-1 hover:shadow-xl`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</HomeLink>
|
||||||
|
) : (
|
||||||
|
<div className={card}>{body}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
328
src/pages/sections/home/HeroStage.tsx
Normal file
328
src/pages/sections/home/HeroStage.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HERO STAGE
|
||||||
|
|
||||||
|
The top of the front page, in whichever mode the admin set:
|
||||||
|
|
||||||
|
brand drifting colour and slow concentric rings — many
|
||||||
|
circles, one centre — behind the words, with the
|
||||||
|
dove at that centre
|
||||||
|
photos the hero photos, crossfading with a slow zoom, a
|
||||||
|
progress bar per photo and a pause button (anything
|
||||||
|
that moves on its own for more than five seconds
|
||||||
|
needs one)
|
||||||
|
livestream the stream beside the words, with a LIVE badge
|
||||||
|
|
||||||
|
A mode that has nothing to show falls back to brand: photos with
|
||||||
|
no photos, or a livestream link that can't be embedded. A stream
|
||||||
|
link that can't be framed still gets a "Watch live" button, so
|
||||||
|
switching the mode on is never a no-op.
|
||||||
|
|
||||||
|
`hero` is null while the page config loads or when it failed; the
|
||||||
|
stage still draws, empty, so the page doesn't jump when it
|
||||||
|
arrives. The error itself is shown by Home, not here.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import HomeLink from './HomeLink.tsx'
|
||||||
|
import DoveMark from '../../../components/DoveMark.tsx'
|
||||||
|
import { livestreamEmbedUrl } from '../../../lib/embeds.ts'
|
||||||
|
import { heroPhoto } from '../../../lib/media.ts'
|
||||||
|
import type { Hero, HeroSlide } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
const DEEP = '#04262e'
|
||||||
|
|
||||||
|
export default function HeroStage({ hero }: { hero: Hero | null }) {
|
||||||
|
const slides = (hero?.slides ?? []).filter((slide) => heroPhoto(slide.media))
|
||||||
|
const embed = livestreamEmbedUrl(hero?.livestream?.url)
|
||||||
|
|
||||||
|
const mode =
|
||||||
|
hero?.mode === 'photos' && slides.length > 0
|
||||||
|
? 'photos'
|
||||||
|
: hero?.mode === 'livestream' && hero.livestream
|
||||||
|
? 'livestream'
|
||||||
|
: 'brand'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="hero"
|
||||||
|
className="relative isolate flex min-h-[92vh] items-center overflow-hidden pt-24 pb-16"
|
||||||
|
style={{ background: DEEP }}
|
||||||
|
>
|
||||||
|
{mode === 'photos' ? (
|
||||||
|
<PhotoBackdrop slides={slides} seconds={hero?.slide_seconds ?? 7} />
|
||||||
|
) : (
|
||||||
|
<BrandBackdrop />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 mx-auto grid w-full max-w-7xl items-center gap-12 px-6 lg:grid-cols-12">
|
||||||
|
<div className={mode === 'livestream' ? 'lg:col-span-5' : 'lg:col-span-8'}>
|
||||||
|
{hero && <Words hero={hero} live={mode === 'livestream'} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'livestream' && hero?.livestream && (
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<LiveFrame src={embed} url={hero.livestream.url} title={hero.livestream.title} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The words ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function Words({ hero, live }: { hero: Hero; live: boolean }) {
|
||||||
|
const words = hero.headline.split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-white">
|
||||||
|
{live ? (
|
||||||
|
<p className="mb-6 inline-flex items-center gap-3 rounded-full bg-white/10 px-4 py-1.5 text-sm font-semibold uppercase tracking-[0.2em] backdrop-blur">
|
||||||
|
<span className="hp-live-dot h-2.5 w-2.5 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||||
|
Live now
|
||||||
|
{hero.livestream?.title && (
|
||||||
|
<span className="normal-case tracking-normal text-white/75">
|
||||||
|
· {hero.livestream.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
hero.eyebrow && (
|
||||||
|
<p className="mb-6 inline-block rounded-full border border-white/20 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.25em] text-[#9fe7d0]">
|
||||||
|
{hero.eyebrow}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className={`font-display font-extrabold leading-[0.95] tracking-tight ${
|
||||||
|
live ? 'text-5xl md:text-6xl' : 'text-6xl md:text-8xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{words.map((word, index) => (
|
||||||
|
<span key={`${word}-${index}`}>
|
||||||
|
<span
|
||||||
|
className="hp-rise"
|
||||||
|
style={{ animationDelay: `${120 + index * 110}ms` }}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{hero.subhead && (
|
||||||
|
<p
|
||||||
|
className="hp-rise mt-8 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl"
|
||||||
|
style={{ animationDelay: `${200 + words.length * 110}ms` }}
|
||||||
|
>
|
||||||
|
{hero.subhead}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hero.primary || hero.secondary) && (
|
||||||
|
<div
|
||||||
|
className="hp-rise mt-10 flex flex-wrap gap-4"
|
||||||
|
style={{ animationDelay: `${320 + words.length * 110}ms` }}
|
||||||
|
>
|
||||||
|
{hero.primary && (
|
||||||
|
<HomeLink
|
||||||
|
url={hero.primary.url}
|
||||||
|
className="rounded-full px-8 py-4 text-lg font-bold text-[#04262e] shadow-xl transition-transform duration-300 hover:-translate-y-0.5 hover:scale-[1.03]"
|
||||||
|
style={{ background: 'linear-gradient(120deg, #9fe7d0, #5ce7ff)' }}
|
||||||
|
>
|
||||||
|
{hero.primary.label}
|
||||||
|
</HomeLink>
|
||||||
|
)}
|
||||||
|
{hero.secondary && (
|
||||||
|
<HomeLink
|
||||||
|
url={hero.secondary.url}
|
||||||
|
className="rounded-full border border-white/35 px-8 py-4 text-lg font-semibold text-white transition-colors duration-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{hero.secondary.label} →
|
||||||
|
</HomeLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Brand backdrop ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function BrandBackdrop() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 -z-10" aria-hidden="true">
|
||||||
|
<div className="hp-aurora hp-aurora--a" style={blob('#138ba0', '48vw', '-10%', '-10%')} />
|
||||||
|
<div className="hp-aurora hp-aurora--b" style={blob('#10d48a', '38vw', '45%', '20%')} />
|
||||||
|
<div className="hp-aurora hp-aurora--c" style={blob('#d8b64a', '30vw', '70%', '-5%')} />
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className="absolute -right-[20vw] top-1/2 h-[120vw] w-[120vw] -translate-y-1/2 md:-right-[10vw] md:h-[80vw] md:w-[80vw]"
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="hp-dove-glow">
|
||||||
|
<stop offset="0%" stopColor="#9fe7d0" stopOpacity="0.35" />
|
||||||
|
<stop offset="100%" stopColor="#9fe7d0" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* The rings are faint; the dove at their centre is not, so
|
||||||
|
the opacity sits on the rings rather than the whole SVG. */}
|
||||||
|
<g opacity="0.16">
|
||||||
|
<g className="hp-rings" fill="none" stroke="#ffffff">
|
||||||
|
{[40, 70, 100, 130, 160, 190].map((r, i) => (
|
||||||
|
<circle key={r} cx="200" cy="200" r={r} strokeWidth={i % 2 ? 0.6 : 1.2} strokeDasharray={i % 2 ? '2 6' : undefined} />
|
||||||
|
))}
|
||||||
|
<circle cx="390" cy="200" r="4" fill="#9fe7d0" stroke="none" />
|
||||||
|
<circle cx="200" cy="40" r="3" fill="#5ce7ff" stroke="none" />
|
||||||
|
</g>
|
||||||
|
<g className="hp-rings hp-rings--reverse" fill="none" stroke="#9fe7d0">
|
||||||
|
<circle cx="200" cy="200" r="115" strokeWidth="0.8" strokeDasharray="1 10" />
|
||||||
|
<circle cx="85" cy="200" r="3.5" fill="#ffffff" stroke="none" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Outside the rotating groups, so it stays upright while the
|
||||||
|
rings turn around it. 64 × 42.7 at the centre fits inside
|
||||||
|
the innermost ring (r 40) with room to float. */}
|
||||||
|
<circle cx="200" cy="200" r="46" fill="url(#hp-dove-glow)" />
|
||||||
|
<g className="hp-dove" style={{ color: '#ffffff' }} opacity="0.9">
|
||||||
|
<DoveMark x="168" y="178.65" width="64" height="42.7" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 h-40"
|
||||||
|
style={{ background: `linear-gradient(to bottom, transparent, ${DEEP})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function blob(color: string, size: string, left: string, top: string): CSSProperties {
|
||||||
|
return { background: color, width: size, height: size, left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo backdrop ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function PhotoBackdrop({ slides, seconds }: { slides: HeroSlide[]; seconds: number }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const ms = Math.max(3, seconds) * 1000
|
||||||
|
const current = slides[index % slides.length]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || slides.length < 2) return
|
||||||
|
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % slides.length), ms)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [index, paused, ms, slides.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 -z-10"
|
||||||
|
style={{ '--hp-slide-ms': `${ms}ms` } as CSSProperties}
|
||||||
|
>
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<div
|
||||||
|
key={`${slide.media}-${i}`}
|
||||||
|
className={`hp-slide ${i === index ? 'hp-slide--on' : ''}`}
|
||||||
|
aria-hidden={i !== index}
|
||||||
|
>
|
||||||
|
<img src={heroPhoto(slide.media) ?? ''} alt={slide.alt ?? ''} loading={i === 0 ? 'eager' : 'lazy'} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(100deg, rgba(4,38,46,0.92) 0%, rgba(4,38,46,0.65) 45%, rgba(4,38,46,0.15) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-6 z-10 mx-auto flex max-w-7xl flex-wrap items-end gap-4 px-6">
|
||||||
|
{current?.caption && (
|
||||||
|
<p className="max-w-md rounded-xl bg-black/35 px-4 py-2 text-sm text-white/90 backdrop-blur">
|
||||||
|
{current.link_url ? (
|
||||||
|
<HomeLink url={current.link_url} className="hover:underline">
|
||||||
|
{current.caption} →
|
||||||
|
</HomeLink>
|
||||||
|
) : (
|
||||||
|
current.caption
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={`${slide.media}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||||
|
aria-current={i === index}
|
||||||
|
className="h-1.5 w-10 overflow-hidden rounded-full bg-white/25"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
// Re-keyed per index so the fill restarts on every change.
|
||||||
|
key={`${index}-${i}`}
|
||||||
|
className={`hp-progress block h-full bg-white ${
|
||||||
|
i < index ? 'hp-progress--done' : i === index ? 'hp-progress--run' : ''
|
||||||
|
} ${paused ? 'hp-progress--paused' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaused((p) => !p)}
|
||||||
|
aria-label={paused ? 'Play slideshow' : 'Pause slideshow'}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/40 text-xs text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{paused ? '▶' : '❚❚'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Livestream ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function LiveFrame({ src, url, title }: { src: string | null; url: string; title?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-white/15 bg-black shadow-2xl"
|
||||||
|
style={{ boxShadow: '0 30px 80px -20px rgba(16, 212, 138, 0.35)' }}
|
||||||
|
>
|
||||||
|
<div className="aspect-video w-full">
|
||||||
|
{src ? (
|
||||||
|
<iframe
|
||||||
|
src={src}
|
||||||
|
title={title || 'Livestream'}
|
||||||
|
className="h-full w-full"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-white">
|
||||||
|
<span className="hp-live-dot h-4 w-4 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||||
|
<HomeLink
|
||||||
|
url={url}
|
||||||
|
className="rounded-full bg-white px-6 py-3 font-semibold text-[#04262e] hover:scale-105"
|
||||||
|
>
|
||||||
|
Watch live
|
||||||
|
</HomeLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/pages/sections/home/HomeLink.tsx
Normal file
45
src/pages/sections/home/HomeLink.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HOME LINK
|
||||||
|
|
||||||
|
Every link on the front page is typed into the admin, so any of
|
||||||
|
them can be a route (/retreats), an anchor on this page
|
||||||
|
(#connect) or somewhere else entirely. One component decides
|
||||||
|
which element that is, so the hero buttons, photo captions and
|
||||||
|
pathfinder actions can't disagree about it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
type HomeLinkProps = {
|
||||||
|
url: string
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isExternal = (url: string) => /^[a-z][a-z0-9+.-]*:/i.test(url)
|
||||||
|
|
||||||
|
export default function HomeLink({ url, className, style, children }: HomeLinkProps) {
|
||||||
|
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||||
|
return (
|
||||||
|
<Link to={url} className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailto: and tel: open an app, not a tab.
|
||||||
|
const newTab = isExternal(url) && !/^(mailto|tel):/i.test(url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/pages/sections/home/NextEventCountdown.tsx
Normal file
133
src/pages/sections/home/NextEventCountdown.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
NEXT EVENT COUNTDOWN
|
||||||
|
|
||||||
|
A strip under the hero: the next event, and how long until it.
|
||||||
|
The API picks the event (pinned in the admin, or the next one that
|
||||||
|
hasn't ended); this works out the moment to count to.
|
||||||
|
|
||||||
|
For a one-off event that's local midnight on starts_on — dates
|
||||||
|
here are calendar dates with no time attached. For a series it's
|
||||||
|
the next meeting, at the series' start time when it has one, so a
|
||||||
|
weekly class counts down to Tuesday 7pm rather than to a start
|
||||||
|
date months in the past.
|
||||||
|
|
||||||
|
Once the moment passes and the event hasn't ended, the strip says
|
||||||
|
it's happening now instead of counting below zero.
|
||||||
|
|
||||||
|
The strip's heading is the event itself, so the section title and
|
||||||
|
blurb from the admin aren't drawn here.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { eventHref } from '../../../lib/hrefs.ts'
|
||||||
|
import { upcomingOccurrences } from '../../../lib/eventSeries.ts'
|
||||||
|
import type { CountdownEvent } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
|
||||||
|
/* Local midnight (or HH:MM) on a 'YYYY-MM-DD'. */
|
||||||
|
function localMoment(date: string, time?: string | null): Date | null {
|
||||||
|
const [y, m, d] = date.split('-').map(Number)
|
||||||
|
if (!y || !m || !d) return null
|
||||||
|
const [hh, mm] = (time ?? '00:00').split(':').map(Number)
|
||||||
|
return new Date(y, m - 1, d, hh || 0, mm || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function target(event: CountdownEvent): Date | null {
|
||||||
|
if (event.series) {
|
||||||
|
const next = upcomingOccurrences(event.series, event.starts_on, event.ends_on, 1)[0]
|
||||||
|
return next ? localMoment(next, event.series.start_time) : null
|
||||||
|
}
|
||||||
|
return event.starts_on ? localMoment(event.starts_on) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNow(intervalMs: number) {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), intervalMs)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [intervalMs])
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `overlap` tucks the strip up over the hero's bottom edge, which
|
||||||
|
only makes sense when it's the first band after the hero. The
|
||||||
|
admin can move it anywhere, so Home decides. */
|
||||||
|
export default function NextEventCountdown({
|
||||||
|
event,
|
||||||
|
overlap,
|
||||||
|
}: {
|
||||||
|
event: CountdownEvent
|
||||||
|
overlap: boolean
|
||||||
|
}) {
|
||||||
|
const now = useNow(1000)
|
||||||
|
const when = target(event)
|
||||||
|
const accent = event.color || TEAL
|
||||||
|
|
||||||
|
const remaining = when ? when.getTime() - now : 0
|
||||||
|
const live = !when || remaining <= 0
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
['days', Math.floor(remaining / 86_400_000)],
|
||||||
|
['hrs', Math.floor(remaining / 3_600_000) % 24],
|
||||||
|
['min', Math.floor(remaining / 60_000) % 60],
|
||||||
|
['sec', Math.floor(remaining / 1000) % 60],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Next event"
|
||||||
|
className={`relative z-20 px-6 ${overlap ? '-mt-12' : 'py-12'}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={eventHref(event.id)}
|
||||||
|
className="group mx-auto flex max-w-6xl flex-col gap-6 rounded-3xl border bg-white/95 p-6 shadow-2xl backdrop-blur transition-transform duration-300 hover:-translate-y-1 md:flex-row md:items-center md:p-8"
|
||||||
|
style={{ borderColor: `${accent}55` }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.25em]" style={{ color: accent }}>
|
||||||
|
{live ? 'Happening now' : 'Next up'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate font-display text-2xl font-extrabold text-[#073d4a] md:text-3xl">
|
||||||
|
{event.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-[#4a6b72]">
|
||||||
|
{[event.theme && `“${event.theme}”`, event.date_label, where]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!live && (
|
||||||
|
<div className="flex gap-2 md:gap-3" role="timer" aria-live="off">
|
||||||
|
{parts.map(([label, value]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex w-16 flex-col items-center rounded-2xl py-3 text-white md:w-20"
|
||||||
|
style={{ background: `linear-gradient(160deg, ${accent}, #073d4a)` }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-2xl font-bold tabular-nums md:text-3xl">
|
||||||
|
{String(value).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] uppercase tracking-widest text-white/75">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="self-start text-sm font-semibold transition-transform group-hover:translate-x-1 md:self-center"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/pages/sections/home/Pathfinder.tsx
Normal file
186
src/pages/sections/home/Pathfinder.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PATHFINDER — "Find your way in"
|
||||||
|
|
||||||
|
The connect section as a question rather than a wall of forms:
|
||||||
|
pick what you're here for, and that path's actions arrive. Paths
|
||||||
|
and actions are the admin's (Front page → Find your way in);
|
||||||
|
a path with no actions never reaches this component.
|
||||||
|
|
||||||
|
The choices are a real tablist: arrow keys move between them,
|
||||||
|
Home and End jump to the ends, and only the selected tab is in
|
||||||
|
the tab order. Selection follows focus, which is right when
|
||||||
|
showing a panel costs nothing.
|
||||||
|
|
||||||
|
Re-keying the panel on the selected index is what replays the
|
||||||
|
entrance animation for each choice.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useId, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
|
import HomeLink, { isExternal } from './HomeLink.tsx'
|
||||||
|
import type { FrontPagePath } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
type PathfinderProps = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
blurb?: string | null
|
||||||
|
paths: FrontPagePath[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One accent per position, cycling. Paths are data; colour is
|
||||||
|
presentation, so it's assigned here rather than stored. */
|
||||||
|
const ACCENTS = ['#138ba0', '#10a36e', '#c7972b', '#7a5ea8', '#d0643c']
|
||||||
|
|
||||||
|
export default function Pathfinder({ id, title, blurb, paths }: PathfinderProps) {
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
const tabs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
const base = useId().replace(/:/g, '')
|
||||||
|
|
||||||
|
if (paths.length === 0) return null
|
||||||
|
|
||||||
|
const path = paths[Math.min(selected, paths.length - 1)]
|
||||||
|
const accent = ACCENTS[selected % ACCENTS.length]
|
||||||
|
|
||||||
|
const focus = (index: number) => {
|
||||||
|
const next = (index + paths.length) % paths.length
|
||||||
|
setSelected(next)
|
||||||
|
tabs.current[next]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const moves: Record<string, number> = {
|
||||||
|
ArrowRight: selected + 1,
|
||||||
|
ArrowDown: selected + 1,
|
||||||
|
ArrowLeft: selected - 1,
|
||||||
|
ArrowUp: selected - 1,
|
||||||
|
Home: 0,
|
||||||
|
End: paths.length - 1,
|
||||||
|
}
|
||||||
|
if (!(e.key in moves)) return
|
||||||
|
e.preventDefault()
|
||||||
|
focus(moves[e.key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="py-24" style={{ background: '#ffffff' }}>
|
||||||
|
<div className="mx-auto max-w-6xl px-6">
|
||||||
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.3em] text-[#138ba0]">
|
||||||
|
I’m looking to…
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-4 text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={title}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
className="mt-10 flex flex-col gap-3"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{paths.map((option, index) => {
|
||||||
|
const on = index === selected
|
||||||
|
const color = ACCENTS[index % ACCENTS.length]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${option.label}-${index}`}
|
||||||
|
ref={(el) => {
|
||||||
|
tabs.current[index] = el
|
||||||
|
}}
|
||||||
|
id={`${base}-tab-${index}`}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={on}
|
||||||
|
aria-controls={`${base}-panel`}
|
||||||
|
tabIndex={on ? 0 : -1}
|
||||||
|
onClick={() => setSelected(index)}
|
||||||
|
className="group flex items-center gap-4 rounded-2xl border-2 px-5 py-4 text-left transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
borderColor: on ? color : 'rgba(19,139,160,0.12)',
|
||||||
|
background: on ? `${color}12` : '#ffffff',
|
||||||
|
transform: on ? 'translateX(8px)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl transition-transform duration-300 group-hover:scale-110"
|
||||||
|
style={{ background: on ? color : `${color}1f` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{option.icon || '•'}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-xl font-bold" style={{ color: on ? color : '#073d4a' }}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xl transition-opacity"
|
||||||
|
style={{ color, opacity: on ? 1 : 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
key={selected}
|
||||||
|
id={`${base}-panel`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`${base}-tab-${selected}`}
|
||||||
|
className="relative overflow-hidden rounded-[2rem] p-8 md:p-10 lg:col-span-7"
|
||||||
|
style={{ background: `linear-gradient(155deg, ${accent}14, ${accent}05 60%, #ffffff)` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute -right-6 -top-10 select-none text-[10rem] leading-none opacity-10"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{path.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{path.blurb && (
|
||||||
|
<p className="hp-pop relative max-w-md font-display text-2xl font-semibold leading-snug text-[#073d4a]">
|
||||||
|
{path.blurb}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="relative mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
{path.actions.map((action, index) => (
|
||||||
|
<li
|
||||||
|
key={`${action.url}-${index}`}
|
||||||
|
className="hp-pop"
|
||||||
|
style={{ animationDelay: `${120 + index * 90}ms` }}
|
||||||
|
>
|
||||||
|
<HomeLink
|
||||||
|
url={action.url}
|
||||||
|
className="group flex h-full flex-col rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<span className="flex items-start gap-2 font-display text-lg font-bold text-[#073d4a]">
|
||||||
|
{action.label}
|
||||||
|
<span
|
||||||
|
className="ml-auto transition-transform group-hover:translate-x-1"
|
||||||
|
style={{ color: accent }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isExternal(action.url) ? '↗' : '→'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{action.description && (
|
||||||
|
<span className="mt-2 text-sm leading-relaxed text-[#4a6b72]">
|
||||||
|
{action.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</HomeLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
RETREATS BAND
|
||||||
|
|
||||||
|
The National Retreats carousel from the Retreats page, as-is:
|
||||||
|
same component, same filter, so an event edited in the admin
|
||||||
|
shows up identically in both places. This file only adds the
|
||||||
|
front page's heading and a way through to the full page.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import EventListCards from '../EventList-Cards.tsx'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
|
||||||
|
type RetreatsBandProps = { id: string; title: string; blurb?: string | null }
|
||||||
|
|
||||||
|
export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="overflow-hidden py-24" style={{ background: '#eef9fb' }}>
|
||||||
|
<div className="mx-auto mb-12 flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/retreats"
|
||||||
|
className="ml-auto rounded-full border px-5 py-2 text-sm font-semibold transition-colors hover:bg-white"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
All retreats →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventListCards scope="national" type="retreat" view="carousel" accent={TEAL} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue