Compare commits

...

22 commits
v1.1 ... main

Author SHA1 Message Date
4618ad8e67 Version 1.7 Merge pull request 'Admin-driven front page with event calendar' (#7) from feature/alt-front-page into main
Reviewed-on: #7
2026-09-25 12:42:33 +01:00
Zaldimmar
2e125b4629 Add a reusable event calendar and put it on the front page
EventCalendar shows events on a month grid or as a list of the
month. Multi-day events are lane-packed bars that break at week
edges, series events appear on every meeting with their time, and
clicking a day lists everything on it. Phones get the list. Visitors
can narrow by scope, type, online only and search, all starting at
"all"; a page can pin section, host or type through props, which
hides that control.

Migration 019 rebuilds front_page_sections to allow a 'calendar'
band and slots it in after the retreats carousel, so it can be
reordered, retitled or hidden from the Front page editor.

useEvents drops its empty fallback so a failed request surfaces as
an error rather than an empty list, and returns the scope list
alongside the events for the calendar's scope filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:28:42 -05:00
Zaldimmar
641ab167b0 Put the NGU dove at the centre of the brand hero's rings
DoveMark carries the dove path from the original home page as its
own component. The hero draws it at the rings' centre, upright while
they turn, with a soft glow and a gentle bob that stops under reduced
motion. The rings' opacity moves from the SVG to their own group so
the dove isn't dimmed with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:49:52 -05:00
Zaldimmar
6a69084de2 Replace the front page with an admin-driven home
The home page is rebuilt from scratch and configured from a new
Front page tab in the admin, backed by migration 017 and served by
GET /api/front-page.

Hero: brand, photos (crossfading slideshow with progress and pause)
or livestream (YouTube/Facebook/Vimeo embed with a LIVE badge),
switched by hand. After it, bands the admin can reorder, retitle or
hide: a countdown to the next event (series-aware), the National
Retreats carousel, a numbers band (typed in or counted from the
database), a horizontal rail of featured timeline entries, and a
"Find your way in" pathfinder replacing the old connect section.

The CRUD engine gains a `singleton` flag: the entity has one row,
made by its migration, and create and delete are refused. The list
screen opens that row and the editor drops the slug, back link and
delete. shapeSeries moves to shape.js so /front-page can share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:37:42 -05:00
b4b013209b Merge pull request 'Add person detail page and link people tiles to it' (#6) from feature/person-detail into main
Reviewed-on: #6
2026-09-25 11:13:29 +01:00
Zaldimmar
6ec2fb240a Add person detail page and link people tiles to it
GET /api/people/:id returns a published person's profile, public
roles (current and past), published events they were billed at or
hosted, and public awards, each under the same visibility rules its
own page applies. public_phone is never sent.

PersonDetail renders it at /people/:id, the route personHref already
pointed at. PeopleTiles links a tile with a people id to that page;
an expandable tile keeps its details panel and the panel carries a
"View full profile" link instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:05:45 -05:00
300c9b74f0 Merge pull request 'Add recurring series to events' (#5) from feature/event-series into main
Reviewed-on: #5
Tested page. Works.
2026-09-25 11:03:06 +01:00
Zaldimmar
5ed7994a64 Add recurring series to events
A Series checkbox under When opens a panel for the schedule:
weekly on chosen weekdays, monthly by date, or monthly by weekday
position, every N weeks or months, with meeting times and an
optional meeting count. starts_on anchors the schedule and ends_on
bounds it, so effective_status needs no change.

Occurrences are derived, not stored. src/lib/eventSeries.ts builds
the schedule label shown on cards and the event page, and the
upcoming dates listed on the event page.

Migration 016 adds the columns; the CRUD engine gains a time type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:46:49 -05:00
aebc1c302b Merge pull request 'Fix the remaining type errors in src/' (#4) from types/remaining-errors into main
Reviewed-on: #4
2026-09-25 10:34:30 +01:00
Zaldimmar
1b2af2bfbd Fix the remaining type errors in src/
- Declare the --ig-fill custom property on React's CSSProperties once,
  instead of casting the style prop in Footer, Home and EventList-Cards.
- Type Footer's Get_In_Touch links as internal or external, so the
  external branch type-checks.
- Pass undefined rather than null for an absent gradient (Home) and
  Instagram URL (EventList-Cards).

tsc --noEmit is now clean, with and without --noImplicitAny.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:33:21 -05:00
9b91a9aa78 Version 1.6 Type-updates - Merge pull request 'Type src/ against API and database shapes, fixing implicit anys' (#3) from types/implicit-any into main
Reviewed-on: #3
2026-09-25 10:25:04 +01:00
Zaldimmar
2428f4412a Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:13:46 -05:00
5286cae9ca Merge pull request 'CLAUDE.md: add commands section, fix role and logo details' (#2) from docs/claude-md-commands into main 2026-09-25 09:25:09 +01:00
Zaldimmar
32d04b63e9 Add commands section to CLAUDE.md and fix role/logo details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 03:24:00 -05:00
Zaldimmar
75c40cfb73 Added update CLAUDE.md 2026-09-25 03:20:11 -05:00
809c3c56c5 Merge pull request 'Add README with test line' (#1) from test-claude into main 2026-09-25 09:13:46 +01:00
Zaldimmar
ea58c5ce3f Add README with test line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 03:06:55 -05:00
Zaldimmar
bce1a3fcf6 Add CLAUDE.md 2026-09-25 03:05:45 -05:00
Zaldimmar
1d84400aef v1.5 - history and timeline as well as many datastructure updates added, polished, fixes 2026-09-25 02:38:51 -05:00
Zaldimmar
1f0aa3078f v1.4 - added admin page and auth 2026-09-25 02:36:49 -05:00
Zaldimmar
5efdafbb97 v1.3 - added an sqlite db and built data structure 2026-09-25 02:35:46 -05:00
Zaldimmar
b0fba52c0e v1.2 - added react router 2026-09-25 02:34:15 -05:00
150 changed files with 26522 additions and 990 deletions

78
CLAUDE.md Normal file
View 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/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy.
- `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.js` is the single source of truth for navigation, routes, and actions (header, footer, pages).
- `api.js` 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.js 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.js` (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
- Sequential files: `001_`, `002_`, ...
- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block.
- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved.
## Integrations
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.

1
README.md Normal file
View file

@ -0,0 +1 @@
Test line added by Claude Code.

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><!-- site:title --></title>
<script src="https://js.churchcenter.com/modal/v1"></script>
<!-- site:head-end -->
</head>
<body>

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

18
server/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "ngu-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"migrate": "node src/migrate-cli.js"
},
"dependencies": {
"@hono/node-server": "^1.0.0",
"hono": "^4.0.0"
}
}

292
server/src/admin-cli.js Normal file
View 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
View 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;
}
}

View 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 };
}

827
server/src/admin-schema.js Normal file
View file

@ -0,0 +1,827 @@
/* ═══════════════════════════════════════════════════════════════
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) index from
migration 007, which is also what stops a second save creating a
duplicate instead of updating the first. */
const timelineExtension = (refKind) => ({
key: "timeline",
table: "timeline_entries",
owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind },
conflict: ["ref_kind", "ref_id"],
when: { column: "in_timeline", value: 1 },
columns: [
// Fixed for this end: an event's entry is always an event entry.
// Declared as a default rather than a form field so the column is
// written without asking.
enumeration(
"kind",
["milestone", "event", "organization", "award", "people"],
{ default: refKind === "organization" ? "organization" : refKind },
),
...timelineFields,
],
});
/* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({
key: "links",
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",
"section_id",
"event_type",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"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: ["section_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
// this only ever asks on create.
enumeration(
"event_type",
["retreat", "class", "workshop", "meeting", "other"],
{ required: true },
),
text("title", { required: true }),
text("theme"),
text("tagline"),
date("starts_on"),
date("ends_on"),
text("date_label"),
enumeration("status", ["upcoming", "past", "cancelled"]),
...placeColumns,
text("org_logo"),
text("event_logo"),
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
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. See migration 016 for 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",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
},
columns: [
text("display_name", { required: true }),
text("sort_name"),
text("pronouns"),
text("tagline"),
text("photo"),
text("bio"),
text("primary_org_id"),
text("public_email"),
text("public_phone"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "private",
table: "person_private",
idColumn: "person_id",
touch: true, // has its own updated_at with no trigger behind it
columns: [
date("birth_date"),
text("private_email"),
text("private_phone"),
text("address"),
text("notes"),
],
},
],
children: [
linksChild("person"),
blocksChild("person"),
{
key: "affiliations",
table: "affiliations",
owner: { column: "person_id" },
// sort_order is where this person sits on that team, so it
// belongs to the team's editor. reindex: false stops this
// form renumbering by row position; declaring the column
// keeps the team's value intact across a save here.
reindex: false,
order: "org_id, team_id, sort_order",
columns: [
text("org_id", { required: true }),
text("team_id"),
...affiliationRole,
int("sort_order"),
],
},
{
key: "person_awards",
table: "person_awards",
owner: { column: "person_id" },
order: "awarded_on",
columns: [
text("award_id", { required: true }),
text("event_id"),
date("awarded_on"),
text("citation"),
bool("is_public"),
],
},
],
};
/* ── Teams ───────────────────────────────────────────────────── */
// A team belongs to exactly one organization, and affiliations
// point at the pair (team_id, org_id) rather than the team alone.
// Two consequences the form has to live with:
//
// · org_id cannot be changed once anyone is filed under the
// team. The composite foreign key has no ON UPDATE CASCADE, so
// SQLite aborts the UPDATE. That surfaces as a constraint
// error, which is the correct answer — reassign the members
// first.
//
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id", { required: true }),
text("name", { required: true }),
text("tagline"),
text("color"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
children: [
// 'team' is already a valid owner_kind in both polymorphic
// tables, and teams_cleanup drops the rows on delete, so a team
// page comes free.
linksChild("team"),
blocksChild("team"),
{
key: "members",
table: "affiliations",
// org_id is inherited from the team rather than asked for:
// the composite foreign key (team_id, org_id) means a member
// of this team can only belong to this team's organization,
// so a second dropdown could only ever be wrong.
owner: { column: "team_id", inherit: { org_id: "org_id" } },
// Row position is the order they appear on the public site.
// This is the only editor that writes it.
order: "sort_order",
columns: [text("person_id", { required: true }), ...affiliationRole],
},
],
};
/* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because
// an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding
// org being deleted.
//
// No children: 'award' is not in the owner_kind CHECK on
// content_blocks or links. If awards ever need a page of their
// own, that CHECK is a table rebuild, so decide before adding one
// rather than after.
const awards = {
key: "awards",
table: "awards",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id"),
text("name", { required: true }),
text("description"),
text("logo"),
int("sort_order"),
],
};
/* ── 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', created by migration 017 and
never 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_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
// org_id rides along so the affiliation row can filter the list
// down to teams of the organization it already names.
teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// 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
View 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();
}

120
server/src/db.js Normal file
View file

@ -0,0 +1,120 @@
/* ═══════════════════════════════════════════════════════════════
DATABASE
One SQLite file, opened once at boot and held for the life of
the process. Node's own sqlite module is used when it's there
(Node 24+), better-sqlite3 otherwise. Their APIs overlap enough
that everything below works against either, as long as you:
• use positional ? parameters, never named ones
• pass 0/1 for booleans, never true/false
• use tx() rather than db.transaction()
Those three rules are the whole compatibility story.
═══════════════════════════════════════════════════════════════ */
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = join(HERE, "migrations");
/* ── Driver selection ──────────────────────────────────────── */
async function loadDriver() {
try {
const { DatabaseSync } = await import("node:sqlite");
return { Driver: DatabaseSync, name: "node:sqlite" };
} catch {
const { default: BetterSqlite3 } = await import("better-sqlite3");
return { Driver: BetterSqlite3, name: "better-sqlite3" };
}
}
/* ── Open ──────────────────────────────────────────────────────
WAL readers never block the writer, which matters the
moment a feedback POST lands mid page-load
NORMAL fsync on checkpoint rather than every commit; safe
under WAL, and much faster
busy wait rather than throw if something else holds the
write lock (a backup, usually)
───────────────────────────────────────────────────────────── */
export async function openDatabase(path) {
const { Driver, name } = await loadDriver();
const db = new Driver(path);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
db.exec("PRAGMA synchronous = NORMAL");
db.exec("PRAGMA busy_timeout = 5000");
db.driverName = name;
return db;
}
/* ── Transactions ──────────────────────────────────────────────
node:sqlite has no db.transaction(), so do it by hand. Runs
fn() and commits, or rolls back and rethrows.
───────────────────────────────────────────────────────────── */
export function tx(db, fn) {
db.exec("BEGIN");
try {
const result = fn();
db.exec("COMMIT");
return result;
} catch (err) {
try {
db.exec("ROLLBACK");
} catch {
/* already rolled back */
}
throw err;
}
}
/* ── Migrations ────────────────────────────────────────────────
Files are NNN_name.sql. The leading number is the version.
PRAGMA user_version tracks how far we've got, so there's no
bookkeeping table and no ordering ambiguity.
Migrations only ever go forward. To undo something, write a
new migration.
───────────────────────────────────────────────────────────── */
export function migrate(db, { log = console.log } = {}) {
const current = db.prepare("PRAGMA user_version").get().user_version;
const files = readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.sort();
let applied = 0;
for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10);
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
}
if (version <= current) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
tx(db, () => {
db.exec(sql);
// Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`);
});
log(`migrated → ${file}`);
applied += 1;
}
const final = db.prepare("PRAGMA user_version").get().user_version;
if (applied === 0) log(`schema up to date (v${final})`);
return final;
}

106
server/src/index.js Normal file
View file

@ -0,0 +1,106 @@
/* ═══════════════════════════════════════════════════════════════
NGU API
Binds to localhost only. nginx is the only thing that talks to
it, which is what lets the routes trust X-Forwarded-For and skip
CORS entirely — in production the API and the site share an
origin, and in development Vite proxies /api so they share one
there too.
═══════════════════════════════════════════════════════════════ */
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { openDatabase, migrate } from "./db.js";
import { rateLimit } from "./rateLimit.js";
import content from "./routes/content.js";
import people from "./routes/people.js";
import history from "./routes/history.js";
import home from "./routes/home.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 PORT = Number(process.env.PORT ?? 3001);
const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
/* ── Boot ────────────────────────────────────────────────────── */
const db = await openDatabase(DB_PATH);
const version = migrate(db);
syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES);
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
/* ── App ─────────────────────────────────────────────────────── */
const app = new Hono();
app.use("*", logger());
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.get("/api/health", (c) =>
c.json({ ok: true, schema: version, driver: db.driverName }),
);
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.
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
app.route("/api/feedback", feedback);
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
app.route("/api/admin/panel", panel);
app.route("/api/auth", auth);
app.route("/api/admin", admin);
app.route("/api/admin", adminEntities);
startSessionSweeper(db);
app.notFound((c) => c.json({ error: "Not found" }, 404));
app.onError((err, c) => {
console.error(err);
// Never leak internals to the browser.
return c.json({ error: "Something went wrong." }, 500);
});
/* ── Serve ───────────────────────────────────────────────────── */
const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) =>
console.log(`listening http://${info.address}:${info.port}`),
);
/* ── Shutdown ──────────────────────────────────────────────────
systemd sends SIGTERM on stop and restart. Closing the handle
flushes the WAL cleanly, which saves a recovery pass on the
next boot.
───────────────────────────────────────────────────────────── */
for (const signal of ["SIGTERM", "SIGINT"]) {
process.on(signal, () => {
console.log(`${signal} — shutting down`);
server.close(() => {
try {
db.close();
} catch {
/* nothing useful to do here */
}
process.exit(0);
});
});
}

15
server/src/migrate-cli.js Normal file
View file

@ -0,0 +1,15 @@
/* Run migrations without starting the server.
Useful in a deploy script, before restarting the unit.
DB_PATH=/var/lib/ngu/ngu.db pnpm migrate
*/
import { openDatabase, migrate } from "./db.js";
const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
const db = await openDatabase(DB_PATH);
const version = migrate(db);
db.close();
console.log(`${DB_PATH} is at schema v${version}`);

View file

@ -0,0 +1,16 @@
-- 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'));

View file

@ -0,0 +1,700 @@
-- ═══════════════════════════════════════════════════════════════
-- 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;

View file

@ -0,0 +1,55 @@
-- ═══════════════════════════════════════════════════════════════
-- 003 AUTHENTICATION
--
-- Two tables: who may sign in, and who currently is signed in.
--
-- There is no self-signup and no registration endpoint. Accounts
-- are created from the CLI, on the box, by someone with shell
-- access. For a handful of staff that's the right trade: no
-- invite flow, no email delivery, no password-reset surface for
-- anyone to attack.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Stored lowercased. The application lowercases on every read
-- and write, so the UNIQUE index is genuinely case-insensitive
-- without depending on a collation.
email TEXT NOT NULL UNIQUE,
name TEXT,
-- Nullable so a Google-only account can exist later with no
-- password at all. A row with both can use either route in.
password_hash TEXT,
-- Google's stable subject id. Nullable, unique when present —
-- SQLite allows any number of NULLs in a unique index.
google_sub TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'viewer')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- One row per active login. The cookie holds a random token; this
-- table holds only its SHA-256, so a database leak doesn't hand
-- anyone a working session.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT,
ip_hash TEXT
) STRICT;
CREATE INDEX sessions_user_idx ON sessions (user_id);
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);

View file

@ -0,0 +1,7 @@
-- 004_award_org.sql
-- Who gave the award. SET NULL rather than CASCADE: retiring a
-- partner org shouldn't erase an award people have received.
ALTER TABLE awards
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);

View file

@ -0,0 +1,28 @@
-- ═══════════════════════════════════════════════════════════════
-- Person bio and primary organization
--
-- bio is one run of prose, not orderable mixed content, so it does
-- not belong in content_blocks — whose owner_kind CHECK would need
-- a full table rebuild to accept 'person' anyway. Paragraphs are
-- blank-line separated and split at render time.
--
-- primary_org_id is nullable on purpose: plenty of people have no
-- home organization worth printing, and ON DELETE SET NULL means
-- deleting an org blanks the reference rather than blocking the
-- delete or leaving a dangling id behind.
--
-- Check the current version before renumbering this file:
-- PRAGMA user_version;
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE people ADD COLUMN bio TEXT;
-- SQLite requires an added REFERENCES column to default to NULL,
-- which is what we want regardless.
ALTER TABLE people ADD COLUMN primary_org_id TEXT
REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS people_primary_org
ON people (primary_org_id);
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -0,0 +1,45 @@
-- ═══════════════════════════════════════════════════════════════
-- v_org_leadership: add bio and primary organization
--
-- The view already carries the rules for who counts as current and
-- public. Adding the two columns the people tiles need keeps those
-- rules in one place instead of being restated by each route.
--
-- Additive only — attachLeadership does SELECT * and shapeLeader
-- picks fields by name, so existing callers are unaffected.
--
-- PRAGMA user_version; -- check before renumbering this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_org_leadership;
CREATE VIEW v_org_leadership AS
SELECT
a.org_id,
a.team_id,
t.name AS team_name,
t.sort_order AS team_sort_order,
a.person_id,
a.title,
a.role,
a.is_owner,
a.sort_order,
p.display_name,
p.sort_name,
p.pronouns,
p.tagline,
p.photo,
p.public_email,
p.location_label,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM affiliations a
JOIN people p ON p.id = a.person_id AND p.is_published = 1
LEFT JOIN teams t ON t.id = a.team_id
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE a.is_public = 1
AND a.ended_on IS NULL
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
PRAGMA user_version = 0; -- ← set to this migration's number

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,73 @@
-- ═══════════════════════════════════════════════════════════════
-- EVENT SERIES
--
-- An event that meets on a schedule — a weekly class, a monthly
-- meeting — is still one row. is_series says the dates repeat; the
-- series_ columns say how. Occurrences are never stored: they are
-- a pure function of these columns plus starts_on and ends_on, and
-- the site works them out when it draws them.
--
-- The event's own dates bound the series. starts_on is the first
-- meeting and anchors everything else: which week an every-other-
-- week series is "on", which day of the month a monthly one keeps,
-- and which weekday it falls on when no day is ticked. ends_on,
-- when set, is the last day it can meet — which is also what keeps
-- effective_status in v_events right with no change to the view.
-- series_count, when set, stops it after that many meetings,
-- whichever comes first.
--
-- series_frequency:
-- weekly on the ticked weekdays, every N weeks
-- monthly_date on starts_on's day of the month (the 13th),
-- every N months; a short month uses its last day
-- monthly_weekday on starts_on's weekday position (2nd Tuesday),
-- every N months; a 5th becomes "last"
--
-- One boolean per weekday rather than a packed text column: each
-- is a checkbox the CRUD engine already knows how to validate and
-- write, and a CHECK can hold it to 0 or 1.
--
-- frequency and interval are NOT NULL with defaults so that a box
-- ticked with nothing else filled in is still a complete schedule —
-- weekly, on starts_on's weekday — and so every existing row gets
-- a valid value without a backfill. They are ignored while
-- is_series is 0.
--
-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a
-- backstop; the admin engine checks the range before it gets here.
--
-- No change to v_events: it is SELECT e.*, so the columns arrive
-- on /events and /events/:id for free.
--
-- No BEGIN...END in this file, so nothing after it is dropped by
-- the migration runner.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE events
ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1));
ALTER TABLE events
ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly'
CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday'));
ALTER TABLE events
ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1);
ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1));
ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1));
ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1));
ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1));
ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1));
ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1));
ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1));
ALTER TABLE events
ADD COLUMN series_start_time TEXT
CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_end_time TEXT
CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_count INTEGER CHECK (series_count >= 1);

View file

@ -0,0 +1,189 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE
--
-- The home page's editable half. One row in front_page — the CHECK
-- on id makes a second one impossible — and ordered collections
-- hanging off it, each replaced wholesale on save the way every
-- other child collection is. Nothing outside this file has a
-- foreign key into any of them, which is what makes that safe.
--
-- front_page the hero: its words, its buttons, and
-- which mode it's in
-- front_page_slides photos the hero cycles through in
-- 'photos' mode
-- front_page_sections which bands the page draws, in what
-- order, under what heading
-- front_page_stats the numbers band; each one typed in or
-- counted from the database
-- front_page_paths the connect section's "I want to…"
-- choices, each with its actions
-- front_page_path_actions
--
-- What stays in code: how each section looks, and the list of
-- section keys. A section is a component, so the CHECK on
-- front_page_sections.section is the list of components that
-- exist; a row can reorder, retitle or hide one, never invent one.
--
-- hero_mode is switched by hand. 'livestream' shows the embed with
-- a LIVE badge until someone switches it back — no schedule, so no
-- guessing whose timezone a start time was typed in.
--
-- countdown_event_id pins the countdown to one event. Null counts
-- down to the next upcoming published event, which is what it
-- should do almost always.
--
-- Stats: source says where the number comes from. 'manual' prints
-- value as typed. 'years_since' reads value as a year and counts up
-- from it. Everything else is a COUNT the API runs, so the band
-- never goes stale. Adding a source is this CHECK, the enum in both
-- descriptor halves, and the query in routes/home.js.
--
-- The seed is the page as it ships: every section, the stats that
-- need no typing, and the Church Center forms that were hardcoded
-- on the old home page, sorted into paths.
--
-- The updated_at trigger is in 018, on its own, so no statement
-- here sits after a BEGIN...END body.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE front_page (
id TEXT PRIMARY KEY CHECK (id = 'home'),
hero_mode TEXT NOT NULL DEFAULT 'brand'
CHECK (hero_mode IN ('brand', 'photos', 'livestream')),
eyebrow TEXT,
headline TEXT NOT NULL DEFAULT 'Next Generation of Unity',
subhead TEXT,
primary_label TEXT,
primary_url TEXT,
secondary_label TEXT,
secondary_url TEXT,
slide_seconds INTEGER NOT NULL DEFAULT 7
CHECK (slide_seconds BETWEEN 3 AND 60),
livestream_url TEXT,
livestream_title TEXT,
countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) STRICT;
CREATE TABLE front_page_slides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
media TEXT NOT NULL, -- filename in public/front-page/, or a URL
alt TEXT,
caption TEXT,
link_url TEXT
) STRICT;
CREATE TABLE front_page_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
section TEXT NOT NULL
CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')),
title TEXT, -- null → the section's own heading
blurb TEXT,
-- Hidden rather than visible, so a freshly added row with nothing
-- ticked is still a blank row the engine can drop.
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
UNIQUE (page_id, section)
) STRICT;
CREATE TABLE front_page_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'years_since', 'regions', 'chapters',
'partners', 'events_held', 'retreats_held',
'people', 'awards_given')),
value TEXT,
suffix TEXT, -- '+', 'k', ' states'
note TEXT
) STRICT;
CREATE TABLE front_page_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL, -- 'Attend'
icon TEXT, -- one emoji
blurb TEXT
) STRICT;
CREATE TABLE front_page_path_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
description TEXT,
url TEXT NOT NULL
) STRICT;
CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order);
-- ── Seed ─────────────────────────────────────────────────────────
INSERT INTO front_page
(id, eyebrow, headline, subhead,
primary_label, primary_url, secondary_label, secondary_url)
VALUES
('home',
'Young adults of the Unity movement',
'Next Generation of Unity',
'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.',
'Find a retreat', '/retreats',
'Find your way in', '#connect');
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES
('home', 0, 'countdown', NULL, NULL),
('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'),
('home', 2, 'stats', 'NGU by the numbers', NULL),
('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'),
('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.');
INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES
('home', 0, 'Regions', 'regions'),
('home', 1, 'Chapters', 'chapters'),
('home', 2, 'Retreats held', 'retreats_held'),
('home', 3, 'Awards given', 'awards_given');
INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES
('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'),
('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'),
('home', 2, 'Belong', '🌱', 'Make NGU your community.'),
('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.');
INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url)
SELECT p.id, a.sort_order, a.label, a.description, a.url
FROM front_page_paths p
JOIN (
SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label,
'National, regional and partner gatherings.' AS description,
'/retreats' AS url
UNION ALL SELECT 'Attend', 1, 'NGU calendar',
'Everything on the schedule, in one place.',
'https://ngu.churchcenter.com/calendar?view=gallery'
UNION ALL SELECT 'Serve', 0, 'Volunteer',
'Lend a hand at a retreat or event.',
'https://ngu.churchcenter.com/people/forms/1176908'
UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory',
'Join our network of speakers, musicians and facilitators.',
'https://ngu.churchcenter.com/people/forms/1173181'
UNION ALL SELECT 'Belong', 0, 'Become a member',
'Join the NGU community officially.',
'https://ngu.churchcenter.com/people/forms/1135816'
UNION ALL SELECT 'Belong', 1, 'Find your region',
'Chapters and regions across the country.',
'/community'
UNION ALL SELECT 'Partner', 0, 'Affiliation form',
'Affiliate your ministry or spiritual organization with NGU.',
'https://ngu.churchcenter.com/people/forms/1135750'
) a ON a.path = p.label
WHERE p.page_id = 'home';

View file

@ -0,0 +1,16 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE updated_at
--
-- Same rule as the other touch triggers in 002: an UPDATE that
-- doesn't set updated_at itself gets it set, which is what the
-- admin engine's optimistic concurrency compares against. On its
-- own because the migration runner may drop anything that follows
-- a BEGIN...END body.
-- ═══════════════════════════════════════════════════════════════
CREATE TRIGGER front_page_touch
AFTER UPDATE ON front_page
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id;
END;

View file

@ -0,0 +1,61 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE: calendar band
--
-- Adds 'calendar' to the sections the front page can draw. The key
-- is a CHECK, and SQLite can't alter a CHECK in place, so the table
-- is rebuilt: new table, copy, drop, rename.
--
-- No PRAGMA foreign_keys dance. front_page_sections only points out
-- (at front_page); nothing points in, so dropping the old table
-- cascades into nothing, and the copy keeps every page_id valid.
--
-- The new band is inserted straight after the retreats carousel,
-- where "what's on" reads naturally, by shifting everything below it
-- down one. If retreats was removed on this box, it goes last.
--
-- Adding another section later is the same three steps: this CHECK,
-- the enum in both descriptor halves, and SECTIONS in Home.tsx.
--
-- No BEGIN...END in this file.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE front_page_sections_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
section TEXT NOT NULL
CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats',
'timeline', 'connect')),
title TEXT,
blurb TEXT,
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
UNIQUE (page_id, section)
) STRICT;
INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden)
SELECT id, page_id, sort_order, section, title, blurb, is_hidden
FROM front_page_sections;
DROP TABLE front_page_sections;
ALTER TABLE front_page_sections_new RENAME TO front_page_sections;
UPDATE front_page_sections
SET sort_order = sort_order + 1
WHERE page_id = 'home'
AND sort_order > COALESCE(
(SELECT sort_order FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home'));
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb)
SELECT 'home',
COALESCE(
(SELECT sort_order + 1 FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections
WHERE page_id = 'home')),
'calendar',
'What''s on',
'Every gathering, class and meeting in one place.'
WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home');

49
server/src/rateLimit.js Normal file
View file

@ -0,0 +1,49 @@
/* ═══════════════════════════════════════════════════════════════
RATE LIMIT
A fixed window counter held in process memory. It resets when
the service restarts and it doesn't survive a second instance,
both of which are fine for one systemd unit on one box.
The client IP comes from X-Forwarded-For, which nginx sets. That
header is only trustworthy because nothing but nginx can reach
this port — it binds to 127.0.0.1. Don't expose the port.
═══════════════════════════════════════════════════════════════ */
export function rateLimit({ windowMs = 60_000, max = 10 } = {}) {
const hits = new Map(); // ip → { count, resetAt }
// Drop expired entries occasionally so the map can't grow forever.
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of hits) {
if (entry.resetAt <= now) hits.delete(ip);
}
}, windowMs).unref();
return async (c, next) => {
const ip =
c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const now = Date.now();
let entry = hits.get(ip);
if (!entry || entry.resetAt <= now) {
entry = { count: 0, resetAt: now + windowMs };
hits.set(ip, entry);
}
entry.count += 1;
if (entry.count > max) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
return c.json(
{ error: "Too many requests. Try again shortly." },
429,
{ "Retry-After": String(retryAfter) },
);
}
await next();
};
}

View 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
View 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
View 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;

View file

@ -0,0 +1,785 @@
/* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api
GET /events list + the section ids
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page
GET /teams list, ?org=slug
GET /teams/:id one team's page
GET /awards list, ?org=slug
GET /awards/:id one award and its recipients
Organizations are one table, so they're one endpoint. A region
and a chapter differ by a handful of fields, which arrive under
`details` rather than as separate routes — that's what lets a
list component be written once and pointed at any kind.
Responses carry their fallbacks already resolved: an event's
`color` is its own or its first host's, and `status` is derived
from the dates when it isn't set. Components read one field and
don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which
band of the Retreats page an event belongs to, the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
An event's hosts are a list, in billing order, and each one is
either an organization or a person — `kind` says which, and
`org_kind` is there for the three organization routes. The
first is the one the colour and logo fell back to, which is why
order is data and not a display choice.
Two things the team and award routes deliberately don't do:
· /teams/:id carries no roster. /teams/:id/people in people.js
already serves it off v_org_leadership in the shape
PeopleTiles wants, and a second shaper here would be the same
visibility rules written twice, free to drift.
· /awards/:id carries no links and no content blocks. 'award'
is not in the owner_kind CHECK on either polymorphic table,
and widening it is a STRICT table rebuild. `description` is
the prose; the recipients are the page.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import {
asBool,
loadBlocks,
loadLinks,
paragraphs,
shapeSeries,
splitLinks,
} from "../shape.js";
const content = new Hono();
// Content changes weekly at most, and a stale minute costs nobody
// anything. stale-while-revalidate keeps the page instant while the
// refresh happens behind it.
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const ORG_KINDS = ["national", "region", "chapter", "partner"];
const marks = (n) => Array(n).fill("?").join(",");
/* ── Loaders ───────────────────────────────────────────────── */
/* Hosts for a batch of events, keyed by event id, in the order
they're billed. Shaped like loadLinks and loadBlocks so the list
route stays one query per collection rather than one per row.
Unpublished hosts are dropped here rather than in the view: the
admin reads the same view and needs to see them. An event whose
only host is unpublished comes back with an empty list, which is
the right answer — there is nobody to name and nowhere to link. */
function loadHosts(db, ids) {
const byEvent = new Map();
if (ids.length === 0) return byEvent;
const rows = db
.prepare(
`SELECT event_id, host_kind, host_id, host_name, host_org_kind
FROM v_event_hosts
WHERE event_id IN (${marks(ids.length)})
AND host_is_published = 1
ORDER BY event_id, sort_order, id`,
)
.all(...ids);
for (const row of rows) {
const list = byEvent.get(row.event_id) ?? [];
list.push(row);
byEvent.set(row.event_id, list);
}
return byEvent;
}
/* ── Shapers ───────────────────────────────────────────────── */
function shapeHost(row) {
return {
kind: row.host_kind, // 'organization' | 'person'
id: row.host_id,
name: row.host_name,
// Which of /regions, /chapters, /partners the slug belongs to.
// Null for a person, whose route needs no disambiguating.
org_kind: row.host_org_kind,
};
}
function shapeEvent(row, links, cardBlocks, hosts = []) {
const { actions, instagram } = splitLinks(links);
return {
id: row.id,
section_id: row.section_id,
event_type: row.event_type,
title: row.title,
theme: row.theme,
tagline: row.tagline,
starts_on: row.starts_on,
ends_on: row.ends_on,
date_label: row.date_label,
status: row.effective_status,
series: shapeSeries(row),
location_label: row.location_label,
locality: row.locality,
state_code: row.state_code,
country: row.country,
is_online: asBool(row.is_online),
org_logo: row.effective_org_logo,
event_logo: row.event_logo,
color: row.effective_color,
gradient: row.gradient,
hosts: hosts.map(shapeHost),
description: paragraphs(cardBlocks),
links: actions,
instagram,
};
}
/* The common card surface every organization has, whatever kind it
is. Kind-specific fields go in `details`, attached by the caller. */
function shapeOrganization(row, links, cardBlocks, bodyBlocks) {
const { actions, socials, website, email, instagram } = splitLinks(links);
return {
id: row.id,
kind: row.kind,
name: row.name,
short_name: row.short_name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
venue: row.venue,
address: row.address,
locality: row.locality,
state_code: row.state_code,
country: row.country,
location_label: row.location_label,
is_online: asBool(row.is_online),
sort_order: row.sort_order,
description: paragraphs(cardBlocks),
blocks: bodyBlocks,
links: actions,
socials,
website,
email,
instagram,
details: {},
leadership: [],
};
}
function shapeLeader(row) {
return {
person_id: row.person_id,
display_name: row.display_name,
pronouns: row.pronouns,
title: row.title ?? row.tagline,
role: row.role,
is_owner: asBool(row.is_owner),
photo: row.photo,
public_email: row.public_email,
team_id: row.team_id,
team_name: row.team_name,
};
}
/* teams.org_id is NOT NULL and every query below joins a published
organization, so `org` is never absent. */
function shapeTeam(row, links, cardBlocks) {
const { actions, socials, instagram } = splitLinks(links);
return {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
org: { id: row.org_id, name: row.org_name, kind: row.org_kind },
description: paragraphs(cardBlocks),
links: actions,
socials,
instagram,
};
}
/* awards.org_id is nullable — an award can predate any decision
about which organization owns it — so `org` genuinely can be
null, and is also null when the awarding org is unpublished. */
function shapeAward(row) {
return {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
org: row.org_id && row.org_name
? { id: row.org_id, name: row.org_name, kind: row.org_kind }
: null,
};
}
/* ── Kind-specific details, batched ────────────────────────────
Each of these runs a fixed number of queries for the whole list
rather than one per organization.
───────────────────────────────────────────────────────────── */
function attachRegionDetails(db, orgs) {
const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`)
.all(...ids);
const areas = db
.prepare(
`SELECT region_id, area_code, share, edge, note
FROM region_areas
WHERE region_id IN (${marks(ids.length)})
ORDER BY area_code, share DESC`,
)
.all(...ids);
// A region's chapters, enough of each for a list entry.
const children = db
.prepare(
`SELECT c.region_id, o.id, o.name, o.location_label, o.logo
FROM chapters c
JOIN organizations o ON o.id = c.id AND o.is_published = 1
WHERE c.region_id IN (${marks(ids.length)})
ORDER BY o.sort_order, o.name`,
)
.all(...ids);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
const areasBy = new Map();
const childrenBy = new Map();
for (const row of areas) {
const list = areasBy.get(row.region_id);
const entry = {
area_code: row.area_code,
share: row.share,
edge: row.edge,
note: row.note,
};
if (list) list.push(entry);
else areasBy.set(row.region_id, [entry]);
}
for (const row of children) {
const list = childrenBy.get(row.region_id);
const entry = {
id: row.id,
name: row.name,
location_label: row.location_label,
logo: row.logo,
};
if (list) list.push(entry);
else childrenBy.set(row.region_id, [entry]);
}
for (const org of orgs) {
if (org.kind !== "region") continue;
org.details = {
scope: byId[org.id]?.scope ?? null,
map_note: byId[org.id]?.map_note ?? null,
areas: areasBy.get(org.id) ?? [],
chapters: childrenBy.get(org.id) ?? [],
};
}
}
function attachChapterDetails(db, orgs) {
const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT c.id, c.region_id, c.meets, c.started,
r.name AS region_name, r.color AS region_color
FROM chapters c
LEFT JOIN organizations r ON r.id = c.region_id
WHERE c.id IN (${marks(ids.length)})`,
)
.all(...ids);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
for (const org of orgs) {
if (org.kind !== "chapter") continue;
const row = byId[org.id] ?? {};
org.details = {
region_id: row.region_id ?? null,
region_name: row.region_name ?? null,
region_color: row.region_color ?? null,
meets: row.meets ?? null,
started: row.started ?? null,
};
}
}
function attachLeadership(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
if (list) list.push(shapeLeader(row));
else byOrg.set(row.org_id, [shapeLeader(row)]);
}
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
}
/* ── Sections that only an organization's own page wants ───────
Called from /organizations/:id and not from the list. A page
needs them; a card doesn't, and the listing shouldn't pay two
queries for something nothing renders.
───────────────────────────────────────────────────────────── */
/* Every team this organization has, including ones with nobody
currently filed under them. `leadership` already carries team_id
and team_name, so the page can group people without this — but
grouping alone would make an empty team invisible rather than
listed, which is the wrong answer for a team that exists. */
function attachTeams(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE org_id IN (${marks(ids.length)}) AND is_published = 1
ORDER BY sort_order, name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
/* The awards this organization gives. awards has no is_published
column, so every row is public the moment it exists — see the
note in the route below. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT a.id, a.org_id, a.name, a.description, a.logo,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
WHERE a.org_id IN (${marks(ids.length)})
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
recipient_count: row.recipient_count,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.awards = byOrg.get(org.id) ?? [];
}
/* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id.
───────────────────────────────────────────────────────────── */
content.get("/events", (c) => {
const db = c.get("db");
const sections = db
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
.all();
const rows = db
.prepare(
`SELECT * FROM v_events
WHERE is_published = 1
ORDER BY section_id, sort_order`,
)
.all();
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "event", ids);
const cards = loadBlocks(db, "event", ids, "card");
const hosts = loadHosts(db, ids);
const events = rows.map((row) =>
shapeEvent(
row,
links.get(row.id) ?? [],
cards.get(row.id) ?? [],
hosts.get(row.id) ?? [],
),
);
return json(c, { sections, events });
});
/* One event, for its own page. */
content.get("/events/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`)
.get(id);
if (!row) return c.json({ error: "No such event" }, 404);
const links = loadLinks(db, "event", [id]).get(id) ?? [];
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
const hosts = loadHosts(db, [id]).get(id) ?? [];
const people = db
.prepare(
`SELECT person_id, display_name, pronouns, tagline, photo, role, title
FROM v_event_people WHERE event_id = ? ORDER BY sort_order`,
)
.all(id);
// Awards presented at this event. person_awards.event_id is the
// only thing that records where a citation was read out, and an
// event page is the one place it reads as news rather than
// trivia.
const awards = db
.prepare(
`SELECT pa.award_id, pa.awarded_on, pa.citation,
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
JOIN awards a ON a.id = pa.award_id
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
)
.all(id)
.map((r) => ({
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
person: { id: r.person_id, name: r.display_name, photo: r.photo },
awarded_on: r.awarded_on,
citation: r.citation,
}));
return json(c, {
event: {
...shapeEvent(row, links, cards, hosts),
blocks: body,
people,
awards,
},
});
});
/* ── Organizations ─────────────────────────────────────────────
GET /organizations every published org
GET /organizations?kind=region one kind
GET /organizations?kind=region,chapter
Whatever the kind, the common card fields are in the same
places, so a list component reads `name`, `color`, `logo` and
`description` without knowing what it's holding, and reaches
into `details` only when it wants kind-specific extras.
───────────────────────────────────────────────────────────── */
content.get("/organizations", (c) => {
const db = c.get("db");
const kindParam = c.req.query("kind");
const kinds = kindParam
? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k))
: [];
if (kindParam && kinds.length === 0) {
return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400);
}
const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : "";
const rows = db
.prepare(
`SELECT * FROM organizations
WHERE is_published = 1 ${filter}
ORDER BY kind, sort_order, name`,
)
.all(...kinds);
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "organization", ids);
const cards = loadBlocks(db, "organization", ids, "card");
const bodies = loadBlocks(db, "organization", ids, "body");
const organizations = rows.map((row) =>
shapeOrganization(
row,
links.get(row.id) ?? [],
cards.get(row.id) ?? [],
bodies.get(row.id) ?? [],
),
);
attachRegionDetails(db, organizations);
attachChapterDetails(db, organizations);
attachLeadership(db, organizations);
return json(c, { organizations });
});
/* One organization's page: region, chapter, partner or NGU. */
content.get("/organizations/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`)
.get(id);
if (!row) return c.json({ error: "No such organization" }, 404);
const links = loadLinks(db, "organization", [id]).get(id) ?? [];
const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "organization", [id], "body").get(id) ?? [];
const organization = shapeOrganization(row, links, cards, body);
const one = [organization];
attachRegionDetails(db, one);
attachChapterDetails(db, one);
attachLeadership(db, one);
attachTeams(db, one);
attachAwards(db, one);
// Everything this organization is hosting or has hosted, whether
// on its own or alongside somebody else. Co-hosting counts: an
// event run jointly by two regions belongs on both pages.
organization.events = db
.prepare(
`SELECT e.id, e.title, e.date_label, e.event_type,
e.effective_status AS status,
e.location_label, e.event_logo,
e.effective_color AS color
FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
ORDER BY e.sort_order`,
)
.all(id);
return json(c, { organization });
});
/* ── Teams ─────────────────────────────────────────────────────
GET /teams every published team
GET /teams?org=mid-atlantic one organization's teams
GET /teams/:id one team's page
An unpublished organization hides its teams too, in both
routes. Without that join a retired chapter's board stays
reachable by URL after the chapter itself has gone.
───────────────────────────────────────────────────────────── */
content.get("/teams", (c) => {
const db = c.get("db");
const org = c.req.query("org");
const rows = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""}
ORDER BY o.sort_order, t.sort_order, t.name`,
)
.all(...(org ? [org] : []));
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "team", ids);
const cards = loadBlocks(db, "team", ids, "card");
return json(c, {
teams: rows.map((row) =>
shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
),
});
});
content.get("/teams/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.id = ? AND t.is_published = 1`,
)
.get(id);
if (!row) return c.json({ error: "No such team" }, 404);
const links = loadLinks(db, "team", [id]).get(id) ?? [];
const cards = loadBlocks(db, "team", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "team", [id], "body").get(id) ?? [];
return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } });
});
/* ── Awards ────────────────────────────────────────────────────
GET /awards every award
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the
moment somebody creates it, and there is no way to draft one.
That was fine while awards only appeared as a line on a
person's record; it is thinner ground now that each has a URL.
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
worth doing before this ships.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
const db = c.get("db");
const org = c.req.query("org");
// The count has to apply exactly the visibility rules the detail
// route does, or a card will promise recipients the page then
// doesn't list.
const rows = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
return json(c, {
awards: rows.map((row) => ({
...shapeAward(row),
recipient_count: row.recipient_count,
})),
});
});
content.get("/awards/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`,
)
.get(id);
if (!row) return c.json({ error: "No such award" }, 404);
// The event join is LEFT twice over: person_awards.event_id is
// ON DELETE SET NULL, and the event may since have been
// unpublished. A citation outlives the occasion it was read at.
//
// awarded_on DESC puts undated rows last in SQLite, which is the
// right end for a recipient nobody has dated yet.
const recipients = db
.prepare(
`SELECT pa.person_id, pa.awarded_on, pa.citation,
p.display_name, p.photo, p.tagline,
e.id AS event_id, e.title AS event_title
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
WHERE pa.award_id = ? AND pa.is_public = 1
ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`,
)
.all(id);
return json(c, {
award: {
...shapeAward(row),
recipients: recipients.map((r) => ({
id: r.person_id,
name: r.display_name,
photo: r.photo,
tagline: r.tagline,
awarded_on: r.awarded_on,
citation: r.citation,
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
})),
},
});
});
export default content;

View file

@ -0,0 +1,130 @@
/* ═══════════════════════════════════════════════════════════════
FEEDBACK ROUTE — the only public write on the site
Everything hostile that will ever reach this service arrives
here, so the defences live here rather than being sprinkled
around:
rate limit applied where this router is mounted (5/min)
honeypot a field real users never see or fill
length caps rejected before anything touches the database
no HTML stored verbatim, escaped at render time
The write is a single INSERT with positional parameters, so it
works on node:sqlite and better-sqlite3 alike. No transaction:
one statement is already atomic.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { createHash, randomBytes } from "node:crypto";
const feedback = new Hono();
const LIMITS = {
name: 120,
email: 254,
message: 5000,
pagePath: 200,
sectionId: 80,
};
const MIN_MESSAGE = 10;
// Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx.
// Anything else falls back to 'general' rather than being rejected —
// a renamed option shouldn't lose someone's submission.
const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"];
/* ── IP hashing ────────────────────────────────────────────────
Stored so repeat abuse from one source is visible during
triage, hashed so the table never holds an address. Without
IP_SALT the salt is regenerated each boot, which makes hashes
incomparable across restarts — fine for dev, set it in
/etc/ngu/api.env for production.
───────────────────────────────────────────────────────────── */
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
if (!process.env.IP_SALT) {
console.warn("IP_SALT unset — feedback ip_hash values reset on restart");
}
function hashIp(ip) {
if (!ip) return null;
return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32);
}
/* ── Input cleaning ──────────────────────────────────────────── */
function clean(value, max) {
if (typeof value !== "string") return "";
return value.trim().slice(0, max);
}
// Deliberately permissive. Rejecting odd-but-valid addresses loses
// real submissions, and the field is optional anyway.
function looksLikeEmail(value) {
return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
/* ── POST /api/feedback ──────────────────────────────────────── */
feedback.post("/", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
// Honeypot. A bot fills every input it finds; a person can't see
// this one. Return success so the bot doesn't learn anything.
if (clean(body.website, 50) !== "") {
return c.body(null, 204);
}
const name = clean(body.name, LIMITS.name);
const email = clean(body.email, LIMITS.email);
const message = clean(body.message, LIMITS.message);
const pagePath = clean(body.pagePath, LIMITS.pagePath);
// The picker holds nav hashes ('#chapters'); the column holds ids.
const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, "");
const feedbackType = TYPES.includes(body.feedbackType)
? body.feedbackType
: "general";
const errors = {};
if (message.length < MIN_MESSAGE) errors.message = "Please write a little more.";
if (!looksLikeEmail(email)) errors.email = "That email doesn't look right.";
if (Object.keys(errors).length > 0) {
return c.json({ error: "Validation failed", fields: errors }, 422);
}
const db = c.get("db");
const result = db
.prepare(
`INSERT INTO feedback
(feedback_type, message, name, email, page_path, section_id,
user_agent, ip_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
feedbackType,
message,
name || null,
email || null,
pagePath || null,
sectionId || null,
clean(c.req.header("user-agent"), 500) || null,
hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()),
);
// created_at and status come from column defaults.
console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`);
return c.body(null, 204);
});
export default feedback;

View file

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

186
server/src/routes/home.js Normal file
View 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);
// Migration 017 creates 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, sort_order
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
View file

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

299
server/src/routes/people.js Normal file
View 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.sort_order, 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;

398
server/src/seed.js Normal file
View file

@ -0,0 +1,398 @@
/* ═══════════════════════════════════════════════════════════════
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("");

169
server/src/shape.js Normal file
View file

@ -0,0 +1,169 @@
/* ═══════════════════════════════════════════════════════════════
SHAPE
Blocks and links are polymorphic: any organization, event, person
or team can own them. Every list endpoint therefore needs the
same move — fetch the parent rows, then fetch all their children
in one query each and stitch.
The alternative is a query per row, which at a dozen events is
invisible and at two hundred is not. Three queries is three
queries whatever the row count, so it may as well be right now.
One limit worth knowing: SQLite caps bound parameters per
statement (999 on older builds). If a list ever exceeds that,
these need chunking. Nothing here comes close.
═══════════════════════════════════════════════════════════════ */
const placeholders = (n) => Array(n).fill("?").join(",");
const asBool = (value) => value === 1;
/* ── Links ─────────────────────────────────────────────────────
Map of owner_id → links, in sort order.
───────────────────────────────────────────────────────────── */
export function loadLinks(db, ownerKind, ids) {
const out = new Map();
if (ids.length === 0) return out;
const rows = db
.prepare(
`SELECT owner_id, kind, platform, label, url, is_primary
FROM links
WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)})
ORDER BY owner_id, sort_order`,
)
.all(ownerKind, ...ids);
for (const row of rows) {
const link = {
kind: row.kind,
platform: row.platform,
label: row.label,
url: row.url,
is_primary: asBool(row.is_primary),
};
const list = out.get(row.owner_id);
if (list) list.push(link);
else out.set(row.owner_id, [link]);
}
return out;
}
/* ── Blocks ────────────────────────────────────────────────────
Map of owner_id → blocks for one slot, items attached.
Two queries: the blocks, then every item belonging to them.
Blocks with no children come back with an empty items array
rather than no key, so the renderer never has to check.
───────────────────────────────────────────────────────────── */
export function loadBlocks(db, ownerKind, ids, slot = "body") {
const out = new Map();
if (ids.length === 0) return out;
const blockRows = db
.prepare(
`SELECT id, owner_id, type, text, media, href
FROM content_blocks
WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)})
ORDER BY owner_id, sort_order`,
)
.all(ownerKind, slot, ...ids);
if (blockRows.length === 0) return out;
const byId = new Map();
for (const row of blockRows) {
const block = {
type: row.type,
text: row.text,
media: row.media,
href: row.href,
items: [],
};
byId.set(row.id, block);
const list = out.get(row.owner_id);
if (list) list.push(block);
else out.set(row.owner_id, [block]);
}
const blockIds = [...byId.keys()];
const itemRows = db
.prepare(
`SELECT block_id, text, detail, url
FROM content_block_items
WHERE block_id IN (${placeholders(blockIds.length)})
ORDER BY block_id, sort_order`,
)
.all(...blockIds);
for (const row of itemRows) {
byId.get(row.block_id)?.items.push({
text: row.text,
detail: row.detail,
url: row.url,
});
}
return out;
}
/* ── Card description ──────────────────────────────────────────
The card slot is paragraphs by convention, so it collapses to
an array of strings — desc_a and desc_b become description[0]
and description[1]. A non-paragraph block in the card slot is
ignored here; put it in the body slot instead.
───────────────────────────────────────────────────────────── */
export function paragraphs(blocks = []) {
return blocks
.filter((block) => block.type === "paragraph" && block.text)
.map((block) => block.text);
}
/* ── Split entity links ────────────────────────────────────────
Socials are lifted out of the list because cards treat them
differently: Instagram is an icon, Register is a button. The
underlying rows are the same table.
───────────────────────────────────────────────────────────── */
export function splitLinks(links = []) {
return {
actions: links.filter((link) => link.kind === "action"),
socials: links.filter((link) => link.kind === "social"),
website: links.find((link) => link.kind === "website")?.url ?? null,
email: links.find((link) => link.kind === "email")?.label ?? null,
instagram:
links.find((link) => link.platform === "instagram")?.label ?? null,
};
}
/* ── 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 };

View file

@ -1,715 +1,86 @@
import { useState, useEffect } from "react";
import nguLogo from "@/NGU_Logo.svg";
import fallLogo from "@/Fall Logo.svg";
import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg";
import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
/*Components*/
import Layout from "./components/Layout.tsx";
{/* 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>
);
/*Libraries*/
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
import RequireRole from "./pages/admin/RequireRole.tsx";
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<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>
);
/*Primary Pages*/
import Home from "./pages/Home.tsx";
import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx";
import History from "./pages/History.tsx";
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" 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>
);
/*Secondary Pages*/
import Feedback from "./pages/Feedback.tsx";
import Giving from "./pages/Giving.tsx";
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" 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 PAGE_LINKS = [
{ label: "Home", tab: "main" },
{ label: "Other", tab: "other" },
{ label: "Resources", tab: "resources" },
];
const PAGE_SECTIONS = {
main: [
{ label: "#About", href: "#about" },
{ label: "#Events", href: "#events" },
{ label: "#Connect", href: "#connect" },
],
other: [],
resources: [],
};
const Footer_Links = [
{ label: "Privacy Policy", href: "#"},
{ label: "Terms of Service", href: "#"},
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
]
const EVENTS = [
{
id: "spring-2025",
title: "Spring Retreat 2026",
theme: "Altering Intertia",
date: "March/April 2026",
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>
))}
</>
);
}
/*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 Terms from "./pages/Terms.tsx";
import NotFound from "./pages/NotFound.tsx";
export default function App() {
{/* NAV functions*/}
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState("main");
const [activeSection, setActiveSection] = useState(null);
const sections = PAGE_SECTIONS[activeTab] ?? [];
const activePage = PAGE_LINKS.find((p) => p.tab === activeTab);
const goToPage = (tab) => {
setActiveTab(tab);
setActiveSection(null);
setMobileMenuOpen(false);
window.scrollTo({ top: 0 });
};
// Highlight the section currently in view, so the second tier
// visibly tracks position within the page.
useEffect(() => {
if (sections.length === 0) return;
const targets = sections
.map((s) => document.querySelector(s.href))
.filter(Boolean);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible) setActiveSection(`#${visible.target.id}`);
},
{ rootMargin: "-120px 0px -60% 0px", threshold: 0 }
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
}, [activeTab]);
{/* Other useState consts and Functions*/}
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 (
<div className="min-h-screen overflow-x-hidden">
{/* ── NAV ─────────────────────────────────────────────── */}
<header className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md">
{/* Tier 1 — site navigation */}
<nav
className="flex items-center justify-between px-6 py-4"
aria-label="Site"
style={{ background: "rgba(0, 69, 82,0.92)" }}
>
<div className="flex items-center gap-8">
<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 className="hidden md:flex items-center gap-6">
{PAGE_LINKS.map((page) => (
<button
key={page.tab}
onClick={() => goToPage(page.tab)}
aria-current={activeTab === page.tab ? "page" : undefined}
className={`text-base pb-1 transition-colors duration-200 border-b-2 ${
activeTab === page.tab
? "text-white font-bold border-[#aac992]"
: "text-white/75 hover:text-white font-bold border-transparent"
}`}
>
{page.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-8">
<a
href="https://ngu.churchcenter.com/giving"
className="hidden md:inline-block px-5 py-2 rounded-full text-sm text-white transition-all duration-200 hover:scale-105 font-bold leading-relaxed"
style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}
>
Give
</a>
<button
className="md:hidden text-white p-2"
aria-label="Open menu"
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>
</div>
</nav>
{/* Tier 2 — sections of the current page */}
{sections.length > 0 && (
<nav
className="hidden md:flex items-center justify-end gap-6 px-6 py-2"
aria-label={`On this page: ${activePage?.label}`}
style={{
background: "rgba(0, 47, 57, 0.92)",
borderBottom: "1px solid rgba(45,200,224,0.15)",
}}
>
{/* Anchors the row to its parent page */}
<span className="mr-auto text-xs text-white/50">
{activePage?.label}
</span>
{sections.map((section) => (
<a
key={section.href}
href={section.href}
aria-current={activeSection === section.href ? "location" : undefined}
className={`text-sm transition-colors duration-200 ${
activeSection === section.href
? "text-[#aac992]"
: "text-white/70 hover:text-[#aac992]"
}`}
>
{section.label}
</a>
))}
</nav>
)}
</header>
{/* Keeps page content clear of the fixed header */}
<div aria-hidden className={sections.length > 0 ? "h-[6.75rem]" : "h-[4.5rem]"} />
{/* Mobile menu */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center overflow-y-auto py-16"
style={{ background: "rgba(7,61,74,0.97)" }}
>
<button
className="absolute top-5 right-6 text-white text-3xl"
aria-label="Close menu"
onClick={() => setMobileMenuOpen(false)}
>
×
</button>
<div className="w-64">
{PAGE_LINKS.map((page) => {
const pageSections = PAGE_SECTIONS[page.tab] ?? [];
const isActive = activeTab === page.tab;
return (
<div key={page.tab}>
<button
onClick={() => goToPage(page.tab)}
aria-current={isActive ? "page" : undefined}
className={`block w-full text-left text-2xl font-bold py-3 transition-colors ${
isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
}`}
>
{page.label}
</button>
{/* Sections nest under the page they belong to */}
{isActive && pageSections.length > 0 && (
<div
className="ml-3 pl-4 mb-2 flex flex-col"
style={{ borderLeft: "1px solid rgba(45,200,224,0.35)" }}
>
{pageSections.map((section) => (
<a
key={section.href}
href={section.href}
className="text-white/80 text-lg py-2 hover:text-[#10d48a] transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{section.label}
</a>
))}
</div>
)}
</div>
);
})}
</div>
<a
href="https://ngu.churchcenter.com/giving"
className="mt-6 px-8 py-3 rounded-full text-white text-lg font-bold"
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)" }}
onClick={() => setMobileMenuOpen(false)}
>
Give
</a>
</div>
)}
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
<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">
<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 ─────────────────────────────────────── */}
<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 */}
<div
className="flex items-stretch transition-transform duration-500 ease-out"
style={{
transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))`,
}}
>
{EVENTS.map((ev, i) => {
const active = i === index;
const past = ev.status === "past";
const color = ev.color || TEAL;
return (
<div
key={ev.id}
className="shrink-0"
style={{
width: SLIDE,
padding: `0 calc(${GAP} / 2)`,
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>
{/* 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>
{/* ── FOOTER ─ Using #042f3a for BG ───────────────────────────────────── */}
<footer className="py-16 px-6" style={{ background: "rgba(0, 47, 58, 0.92)" }}>
<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(({label, href, Icon}) => (
<a key={label} href={href} className="footer-icons w-10 h-10 rounded-full flex items-center justify-center text-white/70 transition-colors" style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}>
{label == "Instagram" ? (<InstagramIcon id={`ig-footer`} />) : (<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-[#aac992] text-sm transition-colors">{link.label}</a>
))}
</div>
</div>
</div>
</footer>
</div>
<BrowserRouter>
<Routes>
{/* Layout renders the nav once and swaps page content into <Outlet /> */}
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="retreats" element={<Retreats />} />
<Route path="/events/:id" element={<EventDetail />} />
<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="resources" element={<Resources />} />
<Route path="history" element={<History />} />
<Route path="feedback" element={<Feedback />} />
<Route path="give" element={<Giving />} />
<Route path="privacy" element={<Privacy />} />
<Route path="terms" element={<Terms />} />
<Route path="*" element={<NotFound />} />
</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>
</BrowserRouter>
);
}

View file

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 221 KiB

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="403.64276mm"
height="86.755943mm"
viewBox="0 0 403.64273 86.755943"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<g
id="layer10"
style="display:inline"
transform="translate(-344.28592,167.60401)">
<g
id="g3">
<circle
style="display:inline;fill:#008da9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.634488;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path205602-8-3-1"
cx="403.82111"
cy="-121.75806"
r="40.889923" />
<path
id="path1887-1-3-7-7-6-2"
style="color:#000000;display:inline;fill:#008da9;fill-opacity:1;stroke:none;stroke-width:2.284;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 443.45159,-167.57122 c -8.63995,3.71591 -17.05927,7.97953 -25.85742,11.27148 -3.76442,1.391 -7.68514,2.52606 -11.12305,4.64648 -3.58916,2.42042 -5.95825,6.25942 -7.35135,10.29228 -1.23335,3.0863 -2.32136,6.17712 -3.84982,9.11983 -3.67393,-2.17848 -7.69746,-4.08371 -12.04108,-4.24843 -4.09333,-0.39358 -8.26381,0.55262 -11.85512,2.53592 -6.41916,3.13465 -11.92468,7.82515 -18.56868,10.51862 -2.73797,1.21486 -5.51687,2.3331 -8.27926,3.49077 -0.88252,2.47734 0.77854,5.31191 3.20508,6.10351 4.27199,1.7128 8.98018,1.56772 13.49433,1.35771 4.71612,-0.31488 9.32779,-1.47771 13.7933,-2.98449 4.28568,-1.20657 8.61172,-2.23873 13.00143,-3.03728 -2.64145,2.85774 -6.27041,4.39222 -9.272,6.78523 -0.9862,0.76271 -1.85771,1.89939 -1.64657,3.21845 0.1709,1.85512 1.7211,3.08775 2.9593,4.29677 2.6421,2.35246 5.95831,4.07918 9.49722,4.487017 1.35103,0.106385 2.71085,-0.642417 3.25697,-1.900747 1.43582,-2.68121 2.25426,-5.67671 4.35603,-7.93655 1.20099,-1.4152 3.08751,-1.77888 4.83141,-2.01081 3.79664,-0.62592 7.50391,-2.47133 9.93534,-5.50938 1.37471,-1.65305 2.10201,-3.71602 2.52235,-5.79656 0.1379,-0.6722 1.04486,-0.68506 1.58622,-0.85974 1.78218,-0.35339 3.91283,-0.77776 4.83392,-2.54366 0.89776,-1.84635 -0.0715,-4.04503 -1.61189,-5.21854 -2.41639,-1.86664 -5.65021,-3.01579 -8.63111,-2.61499 2.2258,-1.54005 4.74178,-2.58917 7.19752,-3.70372 6.31792,-2.71081 13.11161,-4.94567 18.40937,-9.54069 3.73885,-3.20962 6.79577,-7.24801 8.78271,-11.76096 0.80138,-2.0522 1.44232,-4.33553 0.95805,-6.5376 -0.3398,-1.04637 -1.34059,-2.15906 -2.5332,-1.91992 z" />
<path
id="path92116-3-2-7-1-4"
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.222835;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 401.4397,-85.357733 -5.07733,3.7999 c 1.40469,0.28813 3.37038,0.49431 4.1477,0.56482 0,0 13.4726,-8.14749 21.27399,-14.00284 7.80139,-5.855347 13.28514,-13.045137 15.64699,-18.030857 4.23462,-8.93904 5.76755,-17.18121 4.93866,-21.94278 -2.3532,12.13055 -6.84548,20.1394 -8.91525,22.66728 -2.77658,3.39114 -7.3818,10.79482 -14.1263,15.035397 -8.02485,5.04559 -16.87157,10.25674 -17.88846,11.90908 z" />
<path
fill="#519259"
d="m 401.25566,-85.224563 c 2.11431,-7.90893 7.29014,-19.728807 13.53049,-25.077287 3.15174,-2.74769 6.75532,-5.34691 11.45401,-7.64618 4.36158,-2.05194 6.70709,-3.73973 9.19688,-6.81027 3.40267,-3.94053 5.90574,-7.35199 6.96383,-10.47421 0.22347,8.84634 -6.48806,19.37766 -9.02864,23.54668 -6.2265,9.41606 -10.0147,12.608287 -17.50625,17.196147 -4.38236,2.60687 -9.65678,5.68615 -14.61032,9.26512 z"
fill-opacity="1"
fill-rule="nonzero"
id="path184-6-7-6-9-3-2"
style="display:inline;fill:#358a0d;fill-opacity:1;stroke:none;stroke-width:0.625031;stroke-opacity:1" />
<path
style="color:#000000;display:inline;fill:#ffffff;stroke:none;stroke-width:2.284;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 358.52422,-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" />
<path
fill="#519259"
d="m 412.95849,-81.884143 c 4.58067,-1.05723 7.7275,-1.9948 13.67931,-5.91343 5.95181,-3.91863 10.62995,-9.54813 13.02992,-14.097387 2.39997,-4.54926 4.34384,-10.28218 4.83384,-15.81643 0.49,-5.53425 0.14775,-11.25197 -2.10696,-17.66367 0.15927,9.10203 -2.7414,15.06092 -4.17879,18.80503 -1.43739,3.74411 -4.80196,9.50687 -8.2717,13.57264 -3.46184,4.223207 -7.82036,7.647487 -12.67981,10.383567 -5.38026,3.05578 -13.21504,8.4585 -16.7484,11.61577 4.01135,0.4226 9.12694,-0.10316 12.44259,-0.88609 z"
fill-opacity="1"
fill-rule="nonzero"
id="path182-2-6-6-7-2-2"
style="display:inline;fill:#358a0d;fill-opacity:1;stroke:none;stroke-width:0.717458;stroke-opacity:1" />
</g>
<g
id="g40616-9"
transform="translate(13.096206,-216.39844)"
style="display:inline">
<g
id="g2-4">
<text
xml:space="preserve"
style="font-size:34.109px;line-height:1.25;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;display:inline;stroke-width:0.852729"
x="451.59149"
y="89.758339"
id="text156858-3-8"><tspan
id="tspan156856-8-82"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:34.109px;font-family:Poppins;-inkscape-font-specification:'Poppins, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#008da9;fill-opacity:1;stroke-width:0.852729"
x="451.59149"
y="89.758339">Next Generation</tspan></text>
<text
xml:space="preserve"
style="font-size:33.8136px;line-height:1.25;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;display:inline;stroke-width:0.845345"
x="469.26224"
y="119.08144"
id="text156858-3-1-4"><tspan
id="tspan156856-8-8-5"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:33.8136px;font-family:Poppins;-inkscape-font-specification:'Poppins, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#008da9;fill-opacity:1;stroke-width:0.845345"
x="469.26224"
y="119.08144">of Unity</tspan></text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
/* ═══════════════════════════════════════════════════════════════
ARROW LINK
═══════════════════════════════════════════════════════════════ */
type ArrowLinkProps = {
to: string;
label: string;
color: string;
/** Tailwind size classes for the circle. */
size?: string;
};
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) {
return (
<Link
to={to}
aria-label={label}
title={label}
className={`${size} shrink-0 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110`}
style={{ border: `1px solid ${color}`, color }}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</Link>
);
}

64
src/components/Banner.tsx Normal file
View file

@ -0,0 +1,64 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { SITE_BANNER } from "../data/bannerConfig.js";
export default function Banner() {
const [visible, setVisible] = useState(false);
const storageKey = `ngu-banner-dismissed:${SITE_BANNER.id}`;
useEffect(() => {
if (!SITE_BANNER.enabled) return;
if (!SITE_BANNER.dismissible) return setVisible(true);
try {
if (!localStorage.getItem(storageKey)) setVisible(true);
} catch {
setVisible(true); // storage blocked — show it anyway
}
}, [storageKey]);
const dismiss = () => {
setVisible(false);
try { localStorage.setItem(storageKey, "1"); } catch {}
};
if (!visible) return null;
const linkClass =
"underline underline-offset-4 decoration-[#aac992] hover:text-white transition-colors";
return (
<div
className="relative w-full px-12 py-2.5 text-center"
style={{ background: "rgba(0, 47, 57, 0.95)" }}
role="region"
aria-label="Site announcement"
>
<p className="text-sm text-white/85">
{SITE_BANNER.content.map((part, i) => {
if (!part.href) return <span key={i}>{part.text}</span>;
return part.external ? (
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer" className={linkClass}>
{part.text}
</a>
) : (
<Link key={i} to={part.href} className={linkClass}>
{part.text}
</Link>
);
})}
</p>
{SITE_BANNER.dismissible && (
<button
onClick={dismiss}
aria-label="Dismiss announcement"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,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>
)
}

182
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,182 @@
import { Link } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import nguLogo from "../assets/NGU_Logo.svg";
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<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 = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" 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 ds-icon" 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>
);
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 },
];
// Give's destination lives in navConfig so the footer and the nav can't drift.
// Only the label differs down here.
const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy");
/* 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: "Contact Us", to: "/leadership#contact" },
];
const Footer_Links = [
{ label: "Privacy Policy", to: "/privacy" },
{ label: "Terms of Service", to: "/terms" },
];
const footerLinkClass =
"text-white/40 hover:text-[#aac992] text-sm transition-colors";
const pageLinkClass =
"text-white/80 hover:text-[#aac992] text-sm font-semibold transition-colors";
const sectionLinkClass =
"text-white/45 hover:text-[#aac992] text-sm transition-colors";
const headingClass = "text-white/90 text-sm font-semibold mb-4";
export default function Footer() {
return (
<footer className="py-16 px-6" style={{ background: "rgba(0, 47, 58, 0.92)" }}>
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-8 mb-12">
{/* Brand */}
<div className="md:col-span-3 flex justify-center md:justify-start">
<Link to="/">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</Link>
</div>
{/* Site map — mirrors both nav tiers */}
<div className="md:col-span-6 text-center md:text-left">
<nav aria-label="Site map">
<h2 className={headingClass}>Site Map</h2>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-6">
{PAGE_LINKS.map((page) => {
const sections = PAGE_SECTIONS[page.path] ?? [];
return (
<li key={page.path}>
<Link to={page.path} className={pageLinkClass}>
{page.label}
</Link>
{sections.length > 0 && (
<ul className="mt-2 flex flex-col gap-1.5">
{sections.map((section) => (
<li key={section.hash}>
<Link
to={`${page.path}${section.hash}`}
className={sectionLinkClass}
>
{section.label.replace(/^#/, "")}
</Link>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</nav>
{giveAction && (
<a
href={giveAction.to}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-8 px-6 py-2.5 rounded-full text-sm font-bold text-white whitespace-nowrap transition-all duration-200 hover:scale-105"
style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}
>
Support Us
</a>
)}
</div>
{/* Socials + get in touch */}
<div className="md:col-span-3 flex flex-col items-center md:items-start">
<div className="flex gap-4 mb-8">
{Social_Links.map(({ label, href, Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="footer-icons w-10 h-10 rounded-full flex items-center justify-center text-white/70 transition-colors"
style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}
>
{label === "Instagram" ? <InstagramIcon id="ig-footer" /> : <Icon />}
</a>
))}
</div>
<h2 className={headingClass}>Get in Touch</h2>
<ul className="flex flex-col gap-3">
{Get_In_Touch.map((link) => (
<li key={link.label}>
{link.external ? (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={pageLinkClass}
>
{link.label}
</a>
) : (
<Link to={link.to} className={pageLinkClass}>
{link.label}
</Link>
)}
</li>
))}
</ul>
</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) => (
<Link key={link.label} to={link.to} className={footerLinkClass}>
{link.label}
</Link>
))}
</div>
</div>
</div>
</footer>
);
}

325
src/components/Layout.tsx Normal file
View file

@ -0,0 +1,325 @@
import { useState, useEffect, useRef } from "react";
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import Banner from "./Banner.jsx";
import nguLogo from "../assets/NGU_Logo.svg";
import Footer from "./Footer.tsx";
/* ── Right-side nav actions ──────────────────────────────── */
type NavActionItem = {
label: string;
to: string;
variant: string;
external?: boolean;
};
const ACTION_VARIANTS: Record<
string,
{ className: string; style: React.CSSProperties }
> = {
ghost: {
className:
"font-semibold border border-white/30 text-white/85 hover:border-white/60 hover:text-white hover:bg-white/10",
style: {},
},
fancy: {
className: "font-bold text-white hover:scale-105",
style: { background: "linear-gradient(135deg, #008fa8, #88b668)" },
},
};
const ACTION_SIZES = {
desktop: "px-5 py-2 text-sm leading-relaxed",
mobile: "px-8 py-3 text-lg",
};
function NavAction({
item,
size = "desktop",
onClick,
}: {
item: NavActionItem;
size?: keyof typeof ACTION_SIZES;
onClick?: () => void;
}) {
const v = ACTION_VARIANTS[item.variant] ?? ACTION_VARIANTS.ghost;
const className = `inline-block whitespace-nowrap rounded-full transition-all duration-200 ${ACTION_SIZES[size]} ${v.className}`;
return item.external ? (
<a
href={item.to}
target="_blank"
rel="noopener noreferrer"
className={className}
style={v.style}
onClick={onClick}
>
{item.label}
</a>
) : (
<Link to={item.to} className={className} style={v.style} onClick={onClick}>
{item.label}
</Link>
);
}
const navActions = NAV_ACTIONS as NavActionItem[];
/* ── Layout ──────────────────────────────────────────────── */
export default function Layout() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(72);
const headerRef = useRef<HTMLElement>(null);
const location = useLocation();
const sections = PAGE_SECTIONS[location.pathname] ?? [];
const activePage = PAGE_LINKS.find((p) => p.path === location.pathname);
// Close the mobile menu whenever the route changes
useEffect(() => {
setMobileMenuOpen(false);
setActiveSection(null);
}, [location.pathname]);
// The header grows and shrinks: the banner can be dismissed and the
// subnav only exists on some pages. Measure it instead of guessing,
// so the spacer below always matches.
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) =>
setHeaderHeight(entry.contentRect.height)
);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Router does not scroll on navigation. Go to top on a new page,
// or to the anchor if the URL carries one (handles deep links too).
useEffect(() => {
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
return;
}
}
window.scrollTo({ top: 0 });
}, [location.pathname, location.hash, location.key]);
// Highlight whichever section is currently in view
useEffect(() => {
if (sections.length === 0) return;
const targets = sections
.map((s) => document.querySelector(s.hash))
.filter((el): el is Element => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top
)[0];
if (visible) setActiveSection(`#${visible.target.id}`);
},
// Top margin tracks the live header height so the highlight stays
// accurate whether or not the banner is showing.
{ rootMargin: `-${headerHeight + 30}px 0px -60% 0px`, threshold: 0 }
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
}, [location.pathname, headerHeight]);
return (
<div className="min-h-screen overflow-x-hidden">
{/* ── NAV ─────────────────────────────────────────────── */}
<header
ref={headerRef}
className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md"
>
{/* Tier 0 — site-wide announcement */}
<Banner />
{/* Tier 1 — site navigation */}
<nav
className="flex items-center justify-between px-6 py-4"
aria-label="Site"
style={{ background: "rgba(0, 69, 82,0.92)" }}
>
<div className="flex items-center gap-8">
<Link to="/" aria-label="Next Generation of Unity home" className="inline-block">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</Link>
<div className="hidden md:flex items-center gap-6">
{PAGE_LINKS.map((page) => (
<NavLink
key={page.path}
to={page.path}
end={page.path === "/"}
className={({ isActive }) =>
`text-base font-bold pb-1 border-b-2 transition-colors duration-200 ${
isActive
? "text-white border-[#aac992]"
: "text-white/75 hover:text-white border-transparent"
}`
}
>
{page.label}
</NavLink>
))}
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-3">
{navActions.map((item) => (
<NavAction key={item.label} item={item} />
))}
</div>
<button
className="md:hidden text-white p-2"
aria-label="Open menu"
aria-expanded={mobileMenuOpen}
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>
</div>
</nav>
{/* Tier 2 — sections of the current page */}
{sections.length > 0 && (
<nav
className="hidden md:flex items-center px-6 py-2"
aria-label={`On this page: ${activePage?.label}`}
style={{
background: "rgba(0, 47, 57, 0.92)",
borderBottom: "1px solid rgba(45,200,224,0.15)",
}}
>
<span className="text-xs text-white/45">{activePage?.label}</span>
{/* Separates the page label from its sections */}
<span
aria-hidden
className="mx-5 h-4 w-px"
style={{ background: "rgba(45,200,224,0.3)" }}
/>
<div className="flex items-center gap-6">
{sections.map((section) => (
<a
key={section.hash}
href={section.hash}
aria-current={activeSection === section.hash ? "location" : undefined}
className={`text-sm transition-colors duration-200 ${
activeSection === section.hash
? "text-[#aac992]"
: "text-white/75 hover:text-[#aac992]"
}`}
>
{section.label}
</a>
))}
</div>
</nav>
)}
</header>
{/* Keeps page content clear of the fixed header */}
<div aria-hidden style={{ height: headerHeight }} />
{/* Page content */}
<main>
<Outlet />
</main>
{/*Site Footer applied to every page*/}
<Footer />
{/* Mobile menu */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center overflow-y-auto py-16"
style={{ background: "rgba(7,61,74,0.97)" }}
>
<button
className="absolute top-5 right-6 text-white text-3xl"
aria-label="Close menu"
onClick={() => setMobileMenuOpen(false)}
>
×
</button>
<div className="w-64">
{PAGE_LINKS.map((page) => {
const pageSections = PAGE_SECTIONS[page.path] ?? [];
const isActive = location.pathname === page.path;
return (
<div key={page.path}>
<NavLink
to={page.path}
end={page.path === "/"}
className={({ isActive }) =>
`block text-2xl font-bold py-3 transition-colors ${
isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
}`
}
>
{page.label}
</NavLink>
{/* Sections nest under the page they belong to */}
{isActive && pageSections.length > 0 && (
<div
className="ml-3 pl-4 mb-2 flex flex-col"
style={{ borderLeft: "1px solid rgba(45,200,224,0.35)" }}
>
{pageSections.map((section) => (
<a
key={section.hash}
href={section.hash}
className="text-white/80 text-lg py-2 hover:text-[#10d48a] transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{section.label}
</a>
))}
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 flex flex-col items-center gap-3">
{navActions.map((item) => (
<NavAction
key={item.label}
item={item}
size="mobile"
onClick={() => setMobileMenuOpen(false)}
/>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
/* ═══════════════════════════════════════════════════════════════
PAGE SHELL
Just the chrome that makes pages look alike: a page header, then
sections with a thin accent banner between them, alternating
backgrounds, and a left-aligned heading with a blurb and rule.
Whatever goes inside a section is passed as `content` — the shell
doesn't care what it is.
No <main> and no footer here — Layout provides both, so a page
using this shell drops straight into the router's Outlet.
Usage:
<PageShell
title="Community"
intro="One line under the page title."
sections={[
{
id: "local", // matches the nav hash, #local
title: "Local Chapters",
blurb: "Optional line under the section heading.",
accent: "#138ba0", // heading, banner, rule
background: "#eef9fb",
actions: <SomeButton />, // optional, right of the heading
content: <YourStuff />,
},
]}
/>
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
const TEAL = "#138ba0";
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 {
id,
title,
blurb,
accent,
background,
// Optional action bar for this section's heading row — a view
// toggle, a filter, a link. Whatever the section needs; the
// shell just gives it a place to sit.
actions,
content,
} = section;
return (
<section
id={id}
className="py-20"
style={{ background, scrollMarginTop: "5rem" }}
>
{/* Heading — left aligned, optional actions on the right */}
<div className="max-w-6xl mx-auto px-6 mb-10">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-4xl md:text-5xl font-800" style={{ color: accent }}>
{title}
</h2>
{blurb && <p className="mt-2 text-[#4a6b72] max-w-xl">{blurb}</p>}
</div>
{actions}
</div>
<div className="mt-6 h-px w-full" style={{ background: accent, opacity: 0.35 }} />
</div>
{content}
</section>
);
}
export default function PageShell({ title, intro, sections }: PageShellProps) {
return (
<>
{/* Page header */}
<div className="pt-24 pb-12 px-6" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto">
<h1 className="text-5xl md:text-6xl font-800" style={{ color: TEAL }}>
{title}
</h1>
{intro && <p className="mt-3 text-lg text-[#4a6b72] max-w-2xl">{intro}</p>}
</div>
</div>
{sections.map((section, i) => (
<div key={section.id}>
{/* Thin solid banner between sections */}
<div className="h-2" style={{ background: section.accent }} />
<Section section={section} />
{i === sections.length - 1 && (
<div className="h-2" style={{ background: section.accent }} />
)}
</div>
))}
</>
);
}

View file

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

View file

@ -0,0 +1,389 @@
/* PeopleTiles — polaroid-style people list.
Everything is scoped under .pl and driven by custom properties, so a section
can retune it inline: <PeopleTiles style={{ "--pl-base-w": "150px" }} /> */
.pl {
/* geometry */
--pl-scale: 1;
--pl-base-w: 132px;
--pl-w: calc(var(--pl-base-w) * var(--pl-scale));
--pl-gap: calc(0.875rem * var(--pl-scale));
--pl-pad: calc(0.5rem * var(--pl-scale));
--pl-photo-ratio: 1;
--pl-radius: 3px;
/* color */
--pl-accent: #138ba0;
--pl-frame-bg: #fff;
--pl-frame-edge: rgba(15, 23, 42, 0.1);
--pl-photo-bg: #e7edef;
--pl-ink: #16262b;
--pl-muted: #4a6b72;
--pl-rule: rgba(45, 200, 224, 0.3);
--pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5);
container-type: inline-size;
display: flex;
flex-direction: column;
gap: calc(var(--pl-gap) * 1.25);
color: var(--pl-ink);
}
.pl[data-size="sm"] {
--pl-base-w: 104px;
--pl-radius: 2px;
}
.pl[data-size="lg"] {
--pl-base-w: 164px;
--pl-photo-ratio: 4 / 5;
}
/* groups --------------------------------------------------------------- */
.pl__groups {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: calc(var(--pl-gap) * 1.5);
}
.pl[data-align="center"] .pl__groups,
.pl[data-align="center"] .pl__row {
justify-content: center;
}
.pl__group {
display: flex;
flex-direction: column;
gap: calc(var(--pl-gap) * 0.75);
min-width: 0;
}
.pl__group + .pl__group {
padding-left: calc(var(--pl-gap) * 1.5);
border-left: 1px solid var(--pl-rule);
}
.pl__group-head {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pl__group-label {
margin: 0;
font-size: calc(0.9375rem * var(--pl-scale));
font-weight: 600;
letter-spacing: 0.01em;
}
.pl__group-note {
margin: 0;
font-size: calc(0.8125rem * var(--pl-scale));
color: var(--pl-muted);
}
.pl__row {
display: flex;
flex-wrap: wrap;
gap: var(--pl-gap);
margin: 0;
padding: 0;
list-style: none;
}
.pl[data-overflow="scroll"] .pl__row {
flex-wrap: nowrap;
overflow-x: auto;
scroll-snap-type: x proximity;
padding-bottom: 0.35rem;
scrollbar-width: thin;
}
.pl[data-overflow="scroll"] .pl__item {
scroll-snap-align: start;
}
.pl__item {
flex: 0 0 auto;
}
/* tile ----------------------------------------------------------------- */
.pl__tile {
display: block;
width: var(--pl-w);
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: inherit;
}
.pl__tile--button,
.pl__tile--link {
cursor: pointer;
}
.pl__tile--link {
color: inherit;
text-decoration: none;
}
.pl__frame {
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--pl-pad) * 0.9);
padding: var(--pl-pad);
padding-bottom: calc(var(--pl-pad) * 1.6);
background: var(--pl-frame-bg);
border: 1px solid var(--pl-frame-edge);
border-radius: var(--pl-radius);
box-shadow: var(--pl-shadow);
transition: transform 160ms ease, box-shadow 160ms ease;
}
.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame {
transform: rotate(-1.1deg);
}
.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame {
transform: rotate(0.9deg);
}
.pl__tile--button:hover .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);
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--link:focus-visible {
outline: none;
}
.pl__tile--button:focus-visible .pl__frame,
.pl__tile--link:focus-visible .pl__frame {
outline: 2px solid var(--pl-accent);
outline-offset: 3px;
}
.pl__tile[aria-expanded="true"] .pl__frame {
border-color: var(--pl-accent);
box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6);
}
.pl__photo {
display: grid;
place-items: center;
aspect-ratio: var(--pl-photo-ratio);
overflow: hidden;
background: var(--pl-photo-bg);
border-radius: 1px;
}
.pl__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pl__initials {
font-size: calc(1.25rem * var(--pl-scale));
font-weight: 600;
letter-spacing: 0.03em;
color: var(--pl-muted);
}
.pl__caption {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0 calc(var(--pl-pad) * 0.25);
min-height: calc(1.1rem * var(--pl-scale));
}
.pl__name {
font-size: calc(0.875rem * var(--pl-scale));
font-weight: 600;
line-height: 1.25;
}
.pl__title {
font-size: calc(0.75rem * var(--pl-scale));
line-height: 1.3;
color: var(--pl-muted);
}
.pl__badge {
position: absolute;
right: calc(var(--pl-pad) * 0.6);
bottom: calc(var(--pl-pad) * 0.6);
display: grid;
place-items: center;
width: calc(1.25rem * var(--pl-scale));
height: calc(1.25rem * var(--pl-scale));
border-radius: 999px;
background: var(--pl-accent);
color: #fff;
transition: transform 160ms ease;
}
.pl__tile[aria-expanded="true"] .pl__badge {
transform: rotate(180deg);
}
/* bio panel ------------------------------------------------------------ */
.pl__bio {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding: 1rem 1.1rem;
border-left: 3px solid var(--pl-accent);
border-radius: 6px;
background: color-mix(in srgb, var(--pl-accent) 7%, #fff);
max-width: 68ch;
}
.pl__bio-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.pl__bio-name {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
.pl__bio-title {
margin: 0.1rem 0 0;
font-size: 0.875rem;
color: var(--pl-muted);
}
.pl__close {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--pl-frame-edge);
border-radius: 999px;
background: #fff;
color: var(--pl-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.pl__close:hover {
color: var(--pl-ink);
border-color: var(--pl-accent);
}
.pl__facts {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.5rem;
margin: 0;
}
.pl__fact dt {
font-size: 0.6875rem;
font-weight: 600;
color: var(--pl-muted);
}
.pl__fact dd {
margin: 0;
font-size: 0.875rem;
}
.pl__fact a {
color: var(--pl-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.pl__bio-text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
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 {
margin: 0;
font-size: 0.9375rem;
color: var(--pl-muted);
}
.pl__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* narrow layouts ------------------------------------------------------- */
@container (max-width: 640px) {
.pl__group + .pl__group {
padding-left: 0;
padding-top: calc(var(--pl-gap) * 1.1);
border-left: 0;
border-top: 1px solid var(--pl-rule);
width: 100%;
}
}
@supports not (container-type: inline-size) {
@media (max-width: 640px) {
.pl__group + .pl__group {
padding-left: 0;
padding-top: calc(var(--pl-gap) * 1.1);
border-left: 0;
border-top: 1px solid var(--pl-rule);
width: 100%;
}
}
}
@media (prefers-reduced-motion: reduce) {
.pl__frame,
.pl__badge {
transition: none;
}
.pl__tile--button:hover .pl__frame,
.pl__tile--button:focus-visible .pl__frame {
transform: none;
}
}

View file

@ -0,0 +1,785 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
} from "react";
import { Link } from "react-router-dom";
import { get } from "../lib/api.js";
import { isBadId, personHref } from "../lib/hrefs.ts";
import "./PeopleTiles.css";
/**
* PeopleTiles — a horizontal, polaroid-style people list.
*
* Supply data any of four ways:
*
* <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
* sm photo + name
* md photo + name + title
* 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.
*
* Field names follow the API (is_owner, location_label), so a row
* from /api/teams/:id/people drops in unchanged.
*
* Profiles
* A person with a string id is taken to be a people row and links
* to /people/:id. A tile with nothing to expand is that link; an
* expandable one stays the button that opens its panel — a link
* can't sit inside a button — and the panel carries the link
* instead. A hand-written entry with no id links nowhere.
*/
export interface Person {
id?: string | number;
name: string;
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({
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<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
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: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data) => {
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 defined: Partial<Person> = Object.fromEntries(
Object.entries(overrides).filter(([, value]) => value !== undefined),
);
resolved.push({ ...base, ...defined });
}
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,
groups,
size = "md",
scale = 1,
overflow = "wrap",
align = "start",
accent,
tilt = false,
emptyMessage = "No one listed yet.",
onExpand,
className = "",
style,
...rest
}: Omit<
PeopleTilesProps,
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
> & {
people?: Person[];
groups?: PeopleGroup[];
}) {
const baseId = useId().replace(/:/g, "");
const [openKey, setOpenKey] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
const features = SIZE_FEATURES[resolvedSize];
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
const source = groups?.length
? groups
: people?.length
? [{ id: "all", people }]
: [];
return source
.map((group, groupIndex) => ({
...group,
id: group.id ?? `group-${groupIndex}`,
people: (group.people || []).filter(Boolean),
}))
.filter((group) => group.people.length > 0);
}, [groups, people]);
// Close the panel if the person it belongs to disappears.
useEffect(() => {
if (!openKey) return;
const stillThere = resolvedGroups.some((group) =>
group.people.some((person, index) => keyFor(group, person, index) === openKey),
);
if (!stillThere) setOpenKey(null);
}, [openKey, resolvedGroups]);
if (!resolvedGroups.length) {
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
}
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
function toggle(group: PeopleGroup, person: Person, index: number) {
const key = keyFor(group, person, index);
const next = openKey === key ? null : key;
setOpenKey(next);
onExpand?.(next ? person : null, next ? group : null);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && openKey) {
event.stopPropagation();
setOpenKey(null);
const button = rootRef.current?.querySelector<HTMLButtonElement>(
'.pl__tile[aria-expanded="true"]',
);
button?.focus();
}
}
return (
<div
ref={rootRef}
className={`pl ${className}`.trim()}
data-size={resolvedSize}
data-overflow={overflow}
data-align={align}
data-tilt={tilt ? "on" : "off"}
style={
{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
} as CSSProperties
}
onKeyDown={handleKeyDown}
{...rest}
>
<div className="pl__groups">
{resolvedGroups.map((group) => (
<section
key={group.id}
className="pl__group"
style={
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
}
aria-label={group.label || undefined}
>
{(group.label || group.note) && (
<header className="pl__group-head">
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
{group.note && <p className="pl__group-note">{group.note}</p>}
</header>
)}
<ul className="pl__row">
{group.people.map((person, index) => {
const key = keyFor(group, person, index);
const expandable = features.details && hasDetails(person);
const isOpen = expandable && openKey === key;
return (
<li
key={key}
className="pl__item"
style={
person.accent
? ({ "--pl-accent": person.accent } as CSSProperties)
: undefined
}
>
<Tile
person={person}
showTitle={features.title}
expandable={expandable}
isOpen={isOpen}
panelId={`${baseId}-bio`}
onToggle={() => toggle(group, person, index)}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
{open && (
<DetailPanel
id={`${baseId}-bio`}
person={open.person}
group={open.group}
onClose={() => {
setOpenKey(null);
onExpand?.(null, null);
}}
/>
)}
</div>
);
}
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 = (
<span className="pl__frame">
<span className="pl__photo">
<Photo src={photoSrc(person.photo)} name={person.name} />
</span>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && title && <span className="pl__title">{title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
</span>
);
if (!expandable) {
const href = profileHref(person);
return href ? (
<Link to={href} className="pl__tile pl__tile--link">
{content}
</Link>
) : (
<div className="pl__tile">{content}</div>
);
}
return (
<button
type="button"
className="pl__tile pl__tile--button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
>
{content}
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
</button>
);
}
function Photo({ src, name }: { src?: string | null; name: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return (
<span className="pl__initials" aria-hidden="true">
{initials(name)}
</span>
);
}
return (
<img
className="pl__img"
src={src}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function DetailPanel({
id,
person,
group,
onClose,
}: {
id: string;
person: Person;
group: PeopleGroup;
onClose: () => void;
}) {
const org = resolveOrg(person.org);
const title = titleOf(person);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
const tint = person.accent || group?.accent;
const profile = profileHref(person);
return (
<div
id={id}
className="pl__bio"
role="region"
aria-label={`About ${person.name}`}
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
>
<div className="pl__bio-head">
<div>
<p className="pl__bio-name">{person.name}</p>
{title && <p className="pl__bio-title">{title}</p>}
</div>
<button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close details</span>
<span aria-hidden="true">×</span>
</button>
</div>
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{org && (
<div className="pl__fact">
<dt>Home organization</dt>
<dd>
{org.href ? (
<a href={org.href} target="_blank" rel="noreferrer">
{org.name}
</a>
) : (
org.name
)}
</dd>
</div>
)}
{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) => (
<p key={index} className="pl__bio-text">
{paragraph}
</p>
))}
{profile && (
<Link to={profile} className="pl__profile">
View full profile →
</Link>
)}
</div>
);
}
function Chevron() {
return (
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
<path
d="M4 6.5 8 10.5 12 6.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/* ── Helpers ─────────────────────────────────────────────────── */
/* 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}`;
}
function findByKey(groups: PeopleGroup[], key: string | null) {
if (!key) return null;
for (const group of groups) {
for (let index = 0; index < group.people.length; index += 1) {
const person = group.people[index];
if (keyFor(group, person, index) === key) return { group, person };
}
}
return null;
}
/* The seat they hold here, or what they're called when there is no
seat — the same fallback content.js applies to leadership rows. */
function titleOf(person: Person): string | null {
return person.title || person.tagline || null;
}
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
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] || "")
.join("")
.toUpperCase();
}
function resolveOrg(org: Person["org"]) {
if (!org) return null;
if (typeof org === "string") return { name: org, href: undefined };
if (!org.name) return null;
return org;
}

View file

@ -0,0 +1,470 @@
/* ═══════════════════════════════════════════════════════════════
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, type ChangeEvent, type ReactNode } from "react";
import type { FieldErrors } from "../../lib/api.js";
import type {
AdminFieldSpec,
AdminOption,
AdminOptions,
AdminRow,
CollectionSpec,
} from "../../lib/adminSchema.js";
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: unknown, path: string | null | undefined): unknown {
// 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<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
}
export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
const inner = (object?.[head] ?? {}) as AdminRow;
return { ...object, [head]: setPath(inner, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
/* [value, label, the option row it came from]. Manifest options
have no row, which is what filterBy's `!raw` lets through. */
type Choice = [id: string, label: string, raw?: AdminOption];
type FieldProps = {
field: AdminFieldSpec;
/* Whatever the row holds at field.path; shown as text. */
value: unknown;
row?: AdminRow;
options?: AdminOptions | null;
error?: string;
onChange: (value: string | number) => void;
};
export function Field({ field, value, row, options, error, onChange }: FieldProps) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
const text = value == null ? "" : String(value);
let list: Choice[] = [];
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o])
: (field.options ?? []).map((o): Choice =>
typeof o === "string" ? [o, o] : [o[0], o[1]],
);
const { filterBy } = field;
if (filterBy && row) {
list = list.filter(([, , raw]) => !raw || 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: text,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
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={text}>{text} — 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(text) ? text : "#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={text}
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 }: { children: ReactNode }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
type RepeaterProps = {
spec: CollectionSpec;
rows: AdminRow[] | null | undefined;
options?: AdminOptions | null;
errors?: Partial<FieldErrors> | null;
/** Where this collection's rows sit in the server's error keys. */
errorPrefix: string;
onChange: (rows: AdminRow[]) => void;
};
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
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<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const update = (index: number, next: AdminRow) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index: number, delta: number) => {
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: number | null, to: number | null) => {
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] as AdminRow[] | undefined}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
onChange={(value) => update(index, { ...row, [nested.key]: value })}
/>
</div>
))}
</div>
);
})}
</div>
</section>
);
}
type IconButtonProps = {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
children: ReactNode;
};
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
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>
);
}

10
src/data/bannerConfig.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* Types for bannerConfig.js. */
export type BannerPart = { text: string; href?: string; external?: boolean };
export declare const SITE_BANNER: {
enabled: boolean;
id: string;
content: BannerPart[];
dismissible: boolean;
};

21
src/data/bannerConfig.js Normal file
View file

@ -0,0 +1,21 @@
// Site-wide announcement banner. Set `enabled: false` to hide it entirely.
// Bump `id` whenever the message changes — that re-shows the banner to
// people who dismissed the previous one.
export const SITE_BANNER = {
enabled: true,
id: "feedback-2026-09",
// Text renders in order. A part with `href` becomes a link.
content: [
{ text: "This site is brand new, " },
{
text: "please tell us what you think",
href: "/feedback#website",
external: true,
},
{ text: "." },
],
dismissible: true,
};

47
src/data/chapters.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
/* Types for chapters.js: GET /organizations rows with their
kind-specific details lifted to the top level. */
import type { OrganizationListItem, RegionArea, RegionScope } from "../lib/useContent.ts";
import type { AreaSlice, RegionAreaRow } from "./mapGrid.js";
export { initialsFor } from "./organizations.js";
export type Region = OrganizationListItem & {
scope: RegionScope | null;
map_note: string | null;
areas: RegionArea[];
};
export type Chapter = OrganizationListItem & {
region_id: string | null;
region_name: string | null;
region_color: string | null;
meets: string | null;
started: string | null;
/** The map tile this chapter lights up, or null if it has none. */
area_code: string | null;
};
export type Community = {
loading: boolean;
error: Error | null;
regions: Region[];
regionAreas: RegionAreaRow[];
regionById: Record<string, Region>;
domestic: Region[];
international: Region[];
virtual: Region[];
chapters: Chapter[];
chaptersIn: (regionId: string) => Chapter[];
slices: Record<string, AreaSlice[]>;
chapterCounts: Record<string, number>;
regionsForArea: (areaCode: string) => Region[];
areasLabelFor: (regionId: string) => string;
subtextFor: (region: Pick<Region, "id" | "map_note">) => string;
};
export declare function useCommunity(): Community;

146
src/data/chapters.js Normal file
View file

@ -0,0 +1,146 @@
/* ═══════════════════════════════════════════════════════════════
LOCAL CHAPTER DATA
The chapter map's view of the organization list. Regions and
chapters are the same table and the same endpoint; this hook
reshapes them into what a map needs — slices per tile, counts
per tile, chapters grouped by region.
Its return shape is unchanged from the version that called
/regions and /chapters, so LocalChapters didn't have to move.
Two things live elsewhere:
the tile grid src/data/mapGrid.js. Where a state sits never
changes, so it isn't worth a round trip.
the fetch src/data/organizations.js. One endpoint for
every kind, so a section listing regions and
a section listing partners read alike.
═══════════════════════════════════════════════════════════════ */
import { useMemo } from "react";
import {
areasSentence,
initialsFor,
useOrganizations,
} from "./organizations.js";
import {
areaForChapter,
buildAreaSlices,
countChaptersByArea,
} from "./mapGrid.js";
const byName = (a, b) => a.name.localeCompare(b.name);
export { initialsFor };
export function useCommunity() {
const regionsQuery = useOrganizations("region");
const chaptersQuery = useOrganizations("chapter");
const rawRegions = regionsQuery.organizations;
const rawChapters = chaptersQuery.organizations;
return useMemo(() => {
/* ── Regions ───────────────────────────────────────────────
scope and map_note are lifted out of `details` so the rest
of the app doesn't have to know they're kind-specific. */
const regions = rawRegions.map((org) => ({
...org,
scope: org.details?.scope ?? null,
map_note: org.details?.map_note ?? null,
areas: org.details?.areas ?? [],
}));
// Flat, because buildAreaSlices takes it that way.
const regionAreas = regions.flatMap((region) =>
region.areas.map((area) => ({ ...area, region_id: region.id })),
);
/* ── Chapters, each tagged with the tile it lights up ──────
The database stores a real address; which square that maps
to is a rendering question, answered here once rather than
at every call site. */
const chapters = rawChapters.map((org) => ({
...org,
region_id: org.details?.region_id ?? null,
region_name: org.details?.region_name ?? null,
region_color: org.details?.region_color ?? null,
meets: org.details?.meets ?? null,
started: org.details?.started ?? null,
area_code: areaForChapter(org),
}));
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
/* { WA: [{ regionId, name, color, share, edge, note }], ... }
California comes back as two half slices rather than a
primary and a remainder, so the SVG never does arithmetic. */
const slices = buildAreaSlices(regionAreas, regions);
const chapterCounts = countChaptersByArea(chapters);
const chaptersByRegion = new Map();
for (const chapter of chapters) {
if (!chapter.region_id) continue;
const list = chaptersByRegion.get(chapter.region_id);
if (list) list.push(chapter);
else chaptersByRegion.set(chapter.region_id, [chapter]);
}
const chaptersIn = (regionId) => chaptersByRegion.get(regionId) ?? [];
/* Sorted by name to match how the page has always shown them,
rather than by the sort_order the API returns. */
const domestic = regions.filter((r) => r.scope === "domestic").sort(byName);
const international = regions
.filter((r) => r.scope === "international")
.sort(byName);
const virtual = regions.filter((r) => r.scope === "virtual");
const areasLabelFor = (regionId) =>
areasSentence(regionById[regionId]?.areas ?? []);
const subtextFor = (region) => {
const label = areasLabelFor(region.id);
if (label && region.map_note) return `${label} · ${region.map_note}`;
return label || region.map_note || "";
};
const regionsForArea = (areaCode) =>
(slices[areaCode] ?? [])
.map((slice) => regionById[slice.regionId])
.filter(Boolean);
return {
loading: regionsQuery.loading || chaptersQuery.loading,
error: regionsQuery.error ?? chaptersQuery.error,
regions,
regionAreas,
regionById,
domestic,
international,
virtual,
chapters,
chaptersIn,
slices,
chapterCounts,
regionsForArea,
areasLabelFor,
subtextFor,
};
}, [
rawRegions,
rawChapters,
regionsQuery.loading,
regionsQuery.error,
chaptersQuery.loading,
chaptersQuery.error,
]);
}

29
src/data/eventData.d.ts vendored Normal file
View file

@ -0,0 +1,29 @@
/* Types for eventData.js. Rows are GET /events as shapeEvent in
server/src/routes/content.js sends them. */
import type { EventType } from "../lib/eventTypes.ts";
import type { EventListItem, EventSection } from "../lib/useContent.ts";
export type EventFilter = {
section?: string;
host?: string;
status?: EventListItem["status"];
type?: EventType | EventType[];
};
export declare function useEvents(filter?: EventFilter): {
events: EventListItem[];
/** event_sections, in scope order. Empty until loaded. */
sections: EventSection[];
loading: boolean;
error: Error | null;
};
export declare function splitByStatus<T extends { status: string }>(
events?: T[],
): { upcoming: T[]; past: T[] };
export declare function typesPresent<D extends { id: string }>(
events?: Array<{ event_type: string }>,
declared?: readonly D[],
): D[];

92
src/data/eventData.js Normal file
View file

@ -0,0 +1,92 @@
/* ═══════════════════════════════════════════════════════════════
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" }) one host's events
useEvents({ status: "upcoming" }) a home page strip
useEvents({ type: "workshop" }) one kind, wherever it is
useEvents({ type: ["class", "workshop"] })
useEvents() everything
`section` and `type` are different questions and stack rather
than overlap: the section is which band of the page an event
belongs to, the type is what kind of gathering it is. A regional
class matches both { section: "regional" } and { type: "class" }.
The event_sections rows (the scope list) 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.js";
/* One shared empty list, so a memo keyed on `sections` doesn't
restart on every render before the data arrives. */
const NO_SECTIONS = [];
export function useEvents({ section, host, status, type } = {}) {
const { data, error, loading } = useResource("/events");
const all = data?.events;
const sections = data?.sections ?? NO_SECTIONS;
/* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency
list something stable to compare. */
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
const events = useMemo(() => {
let list = all ?? [];
if (section) list = list.filter(e => e.section_id === section);
// `host` is an organization or a person slug, and an event can
// have several of either — co-hosting puts one event on both
// hosts' lists, which is the point.
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
if (status) list = list.filter(e => e.status === status);
if (typeKey) {
const wanted = new Set(typeKey.split(","));
list = list.filter(e => wanted.has(e.event_type));
}
return list;
}, [all, section, host, status, typeKey]);
return { events, sections, 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 };
}
/* Which of the declared types a list actually contains, in
EVENT_TYPES order rather than whatever order the rows arrived
in. A section with one type has nothing to filter, which is what
lets the chip bar hide itself. */
export function typesPresent(events = [], declared = []) {
const seen = new Set(events.map(e => e.event_type));
return declared.filter(entry => seen.has(entry.id));
}

297
src/data/events.js Normal file
View file

@ -0,0 +1,297 @@
/* ═══════════════════════════════════════════════════════════════
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;

7
src/data/feedbackTypes.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/* Types for feedbackTypes.js. */
export type FeedbackType = { id: string; label: string; hint: string };
export declare const FEEDBACK_TYPES: FeedbackType[];
export declare function feedbackTypeLabel(id: string): string;

51
src/data/feedbackTypes.js Normal file
View 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);
}

View file

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

51
src/data/mapGrid.d.ts vendored Normal file
View file

@ -0,0 +1,51 @@
/* Types for mapGrid.js. */
import type { RegionArea } from "../lib/useContent.ts";
export declare const GRID_COLS: number;
export declare const GRID_ROWS: number;
export declare const AREA_NAMES: Record<string, string>;
/** One tile: a state, or a band such as CANADA with a span. */
export type MapArea = {
code: string;
name: string;
col: number;
row: number;
span: number;
isState: boolean;
};
export declare const AREAS: readonly MapArea[];
export declare const AREA_BY_CODE: Record<string, MapArea>;
/** A region_areas row flattened with its region, as buildAreaSlices takes it. */
export type RegionAreaRow = RegionArea & { region_id: string };
/** One region's share of a tile. */
export type AreaSlice = {
regionId: string;
name: string;
color: string | null | undefined;
share: number;
edge: RegionArea["edge"];
note: string | null;
};
type Locatable = {
is_online?: boolean;
country?: string | null;
state_code?: string | null;
};
export declare function areaForChapter(chapter: Locatable | null | undefined): string | null;
export declare function buildAreaSlices(
regionAreas?: RegionAreaRow[],
regions?: Array<{ id: string; name: string; color?: string | null }>,
): Record<string, AreaSlice[]>;
export declare function countChaptersByArea(chapters?: Locatable[]): Record<string, number>;
export declare function areasLabel(regionId: string, regionAreas?: RegionAreaRow[]): string;

174
src/data/mapGrid.js Normal file
View file

@ -0,0 +1,174 @@
/* ═══════════════════════════════════════════════════════════════
MAP GRID
Pure layout. Where each tile sits, what it's called, and how to
work out which tile a chapter belongs to. None of this is in the
database because none of it changes — Rhode Island will not be
moving, and no admin form should offer to move it.
What IS in the database is which regions cover which areas, and
how much of each. That arrives as region_areas rows whose
area_code matches a key in AREAS below. A code with no match
here simply doesn't paint, which is how Africa and the UK exist
as regions with no tile.
═══════════════════════════════════════════════════════════════ */
export const GRID_COLS = 13;
export const GRID_ROWS = 7;
/* ── States ────────────────────────────────────────────────────
[column, row], both 1-based. A tile grid rather than true
geography: every state reads at the same size, it stays
legible on a phone, and there's no map library to load.
───────────────────────────────────────────────────────────── */
const STATE_GRID = {
AK: [1, 1], ME: [13, 1],
WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
DC: [9, 5], RI: [12, 3],
OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
};
/* ── Bands ─────────────────────────────────────────────────────
Wide areas that aren't states. A band is just a tile with a
span, which keeps the renderer from needing a second code path.
───────────────────────────────────────────────────────────── */
const BAND_GRID = {
CANADA: [3, 1, 8], // col, row, span
};
export const AREA_NAMES = {
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
CANADA: "Canada",
};
/* ── One list the renderer walks ───────────────────────────────
States and bands unified, so drawing the map is a single map()
over AREAS rather than two loops with different shapes.
───────────────────────────────────────────────────────────── */
export const AREAS = Object.freeze([
...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({
code,
name: AREA_NAMES[code] ?? code,
col,
row,
span: 1,
isState: true,
})),
...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({
code,
name: AREA_NAMES[code] ?? code,
col,
row,
span,
isState: false,
})),
]);
export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a]));
/* ── Which tile a chapter sits on ──────────────────────────────
The database stores a real address — state_code for US, and a
country code otherwise — rather than a tile name. This is the
one place that translates between the two, so adding a Mexico
band later means a line here and nothing in SQL.
Returns null for anything with no tile, which covers virtual
chapters and any country not drawn.
───────────────────────────────────────────────────────────── */
const COUNTRY_AREA = {
CA: "CANADA", // ISO country code, not California
};
export function areaForChapter(chapter) {
if (!chapter) return null;
if (chapter.is_online) return null;
if (chapter.country === "US") {
return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null;
}
return COUNTRY_AREA[chapter.country] ?? null;
}
/* ── Slices per tile ───────────────────────────────────────────
Turns region_areas rows into what the SVG needs: for each
tile, the regions painting it and the fraction each takes.
A region with share 1 and no edge fills the tile. A shared
tile has one row per region, each declaring its own slice, so
California is two entries of 0.5 rather than a primary plus a
remainder — no arithmetic, and the renderer doesn't need to
know which region "really" owns it.
regionAreas [{ region_id, area_code, share, edge, note }]
regions [{ id, name, color, ... }]
───────────────────────────────────────────────────────────── */
export function buildAreaSlices(regionAreas = [], regions = []) {
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
const byArea = {};
for (const row of regionAreas) {
const area = AREA_BY_CODE[row.area_code];
const region = regionById[row.region_id];
if (!area || !region) continue; // untiled region, or unknown code
(byArea[row.area_code] ??= []).push({
regionId: region.id,
name: region.name,
color: region.color,
share: row.share ?? 1,
edge: row.edge ?? null,
note: row.note ?? null,
});
}
// Full-tile slice first, so a partial slice paints over it.
for (const slices of Object.values(byArea)) {
slices.sort((a, b) => b.share - a.share);
}
return byArea;
}
/* ── Chapter counts per tile ───────────────────────────────────
{ WA: 2, MO: 1, CANADA: 1 }
───────────────────────────────────────────────────────────── */
export function countChaptersByArea(chapters = []) {
const counts = {};
for (const chapter of chapters) {
const code = areaForChapter(chapter);
if (code) counts[code] = (counts[code] ?? 0) + 1;
}
return counts;
}
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
Replaces statesLabel. Note the annotation now comes straight
from the row rather than being looked up in a splits table and
branched on whether this region is the primary.
───────────────────────────────────────────────────────────── */
export function areasLabel(regionId, regionAreas = []) {
return regionAreas
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
.sort()
.join(", ");
}

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

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

21
src/data/organizations.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/* Types for organizations.js. Rows are GET /organizations as
shapeOrganization in server/src/routes/content.js sends them. */
import type { OrgKind } from "../lib/hrefs.ts";
import type { OrganizationListItem, RegionArea } from "../lib/useContent.ts";
export declare function useOrganizations(kind?: OrgKind): {
organizations: OrganizationListItem[];
loading: boolean;
error: Error | null;
};
export declare function orgPath(
org: { id: string; kind?: string | null } | null | undefined,
): string | null;
export declare function areasSentence(
areas?: Array<Pick<RegionArea, "area_code" | "note">>,
): string;
export declare function initialsFor(name?: string): string;

90
src/data/organizations.js Normal file
View file

@ -0,0 +1,90 @@
/* ═══════════════════════════════════════════════════════════════
ORGANIZATIONS
Regions, chapters, partners and NGU itself are one table and one
endpoint. Anything that lists organizations reads them the same
way and filters by kind:
const { organizations } = useOrganizations("region");
const { organizations } = useOrganizations("partner");
const { organizations } = useOrganizations(); // all
Every organization has the same card surface — name, colour,
logo, description, links. What differs by kind sits under
`details`, so a list component can render the common parts
without knowing what it's holding:
region { scope, map_note, areas[], chapters[] }
chapter { region_id, region_name, region_color, meets, started }
partner {}
Each kind is a separate request path, so the cache in api.js
keys them apart and two sections asking for regions share one
fetch.
═══════════════════════════════════════════════════════════════ */
import { useResource } from "../lib/useResource.js";
const EMPTY = { organizations: [] };
export function useOrganizations(kind) {
const path = kind
? `/organizations?kind=${encodeURIComponent(kind)}`
: "/organizations";
const { data, error, loading } = useResource(path);
return {
organizations: data?.organizations ?? [],
loading,
error,
};
}
/* ── Where an organization's page lives ────────────────────────
One place to change when routes move. Kinds with no page of
their own return null, and a list should render no link rather
than a dead one.
───────────────────────────────────────────────────────────── */
const PATHS = {
region: "/regions",
chapter: "/chapters",
partner: "/partners",
};
export function orgPath(org) {
const base = PATHS[org?.kind];
return base ? `${base}/${org.id}` : null;
}
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
The areas a region covers, annotated where it holds only part
of one. Canada is a band on the map rather than somewhere you'd
list, so it's left out of the sentence.
The note comes straight off the row. The old statesLabel had to
work out whether this region was the primary or the secondary of
a split before it knew which note applied; there's no such thing
any more.
───────────────────────────────────────────────────────────── */
export function areasSentence(areas = []) {
return areas
.filter((area) => area.area_code !== "CANADA")
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
.sort()
.join(", ");
}
/* Initials for a card with no logo. "NGU Lynnwood" → "NL", dropping
the org prefix so every card doesn't read "NG". */
export function initialsFor(name = "") {
return name
.replace(/^NGU\s+/i, "")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join("");
}

View file

@ -69,3 +69,143 @@ h1, h2, h3, h4, h5, h6 {
.footer-icons:hover .ig-icon { fill: var(--ig-fill); }
.fb-icon:hover { fill: #0862f7; }
.ds-icon:hover { fill: #5865f1; }
/* Event Card Section */
.ev-card {
container-type: inline-size;
}
.ev-grid {
display: grid;
gap: 0.25rem 1.5rem;
grid-template-columns: 2fr 1fr;
grid-template-areas:
"ngu image"
"info image"
"desc desc";
}
.ev-ngu {
grid-area: ngu;
}
.ev-info {
grid-area: info;
}
.ev-image {
grid-area: image;
justify-self: stretch;
align-self: center;
max-height: 11rem;
}
.ev-desc {
grid-area: desc;
margin-top: 1rem;
}
@container (max-width: 30rem) {
.ev-grid {
grid-template-columns: 1fr;
grid-template-areas:
"ngu"
"info"
"desc";
}
.ev-grid .ev-image {
justify-self: start;
width: auto;
max-height: 7rem;
margin-bottom: 0.5rem;
}
}
/* ═══════════════════════════════════════════════════════════════
SKELETON
Holds roughly a card's height so the page doesn't jump when the
data arrives. The shimmer is decoration, so it goes away for
anyone who has asked for less motion.
═══════════════════════════════════════════════════════════════ */
.skeleton {
border-radius: 1.5rem;
min-height: 22rem;
background: linear-gradient(
100deg,
rgba(0, 0, 0, 0.04) 30%,
rgba(0, 0, 0, 0.08) 50%,
rgba(0, 0, 0, 0.04) 70%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
/* ═══════════════════════════════════════════════════════════════
UNITY REGIONS
Append to index.css. Scoped to .ur- so nothing here reaches the
rest of the site.
═══════════════════════════════════════════════════════════════ */
/* The 0fr → 1fr grid row animates to the panel's real height, so
nobody has to guess a max-height that's wrong the moment a
region's description grows. The inline style sets the value;
this sets how it moves. */
.ur-panel {
display: grid;
transition: grid-template-rows 300ms ease;
}
.ur-chevron {
transition: transform 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.ur-panel,
.ur-chevron {
transition: none;
}
}
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST
Append to index.css. Scoped to .ol- so nothing here reaches the
rest of the site.
═══════════════════════════════════════════════════════════════ */
/* The 0fr → 1fr grid row animates to the panel's real height, so
nobody has to guess a max-height that's wrong the moment a
description grows. The inline style sets the value; this sets
how it moves. */
.ol-panel {
display: grid;
transition: grid-template-rows 300ms ease;
}
.ol-chevron {
transition: transform 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.ol-panel,
.ol-chevron {
transition: none;
}
}

123
src/lib/adminSchema.d.ts vendored Normal file
View file

@ -0,0 +1,123 @@
/* Types for adminSchema.js: the client half of the descriptor-driven
admin CRUD engine. Rows are whatever columns the server-side
descriptor in server/src/admin-schema.js declares, so they stay a
string-keyed record; the descriptors are what's fixed. */
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
* ('region.scope') are side-table objects under their key, and
* child collections are arrays of rows under theirs. */
export type AdminRow = Record<string, unknown>;
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
* along on the lists that filterBy narrows. */
export type AdminOption = {
id: string;
label: string;
kind?: string;
org_id?: string | null;
};
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
export type AdminOptions = Record<string, AdminOption[]>;
export type FieldWidget =
| "text"
| "textarea"
| "select"
| "checkbox"
| "color"
| "number"
| "date"
| "time";
/** A bare value, or [value, label]. */
export type SelectOption = string | readonly [string, string];
/** Show a group or collection only when another field has this value. */
export type FieldCondition = { path: string; value: unknown };
export type AdminFieldSpec = {
path: string;
label: string;
widget?: FieldWidget;
required?: boolean;
full?: boolean;
help?: string;
options?: readonly SelectOption[];
optionsFrom?: string;
blankLabel?: string;
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
/* Set by EntityEdit on the id field rather than in a manifest. */
readOnly?: boolean;
prefix?: string;
prefixPending?: boolean;
placeholder?: string;
};
export type FieldGroupSpec = {
legend: string;
note?: string;
when?: FieldCondition;
fields: AdminFieldSpec[];
};
export type CollectionSpec = {
key: string;
label: string;
addLabel?: string;
note?: string;
when?: FieldCondition;
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
blank: AdminRow;
fields: AdminFieldSpec[];
children?: CollectionSpec[];
};
export type ListColumn = {
key: string;
label: string;
primary?: boolean;
widget?: "bool";
};
export type ListFilter = {
key: string;
label: string;
options?: readonly SelectOption[];
optionsFrom?: string;
};
export type EntitySpec = {
key: string;
label: string;
singular: string;
idLabel: string;
/** "auto": the table assigns the id, so the form shows it rather than asking. */
idKind?: "auto";
/** The one id a singleton entity has. The list opens it directly,
* and the editor offers no slug, back link or delete. */
singleton?: string;
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
slugFrom?: string | string[];
titleFrom?: string;
list: { columns: ListColumn[]; filters: ListFilter[] };
groups: FieldGroupSpec[];
children?: CollectionSpec[];
};
export type AdminEntityKey =
| "organizations"
| "events"
| "people"
| "teams"
| "awards"
| "timeline"
| "front_page";
/* Indexed by route param as often as by name, so any other string
reads as possibly missing. */
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
readonly [key: string]: EntitySpec | undefined;
};
export declare function slugify(value: unknown): string;

1150
src/lib/adminSchema.js Normal file

File diff suppressed because it is too large Load diff

32
src/lib/adminTitle.tsx Normal file
View file

@ -0,0 +1,32 @@
/* ═══════════════════════════════════════════════════════════════
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 type AdminTitleValue = { setDetail: (value: string | null) => void };
export const AdminTitleContext = createContext<AdminTitleValue | null>(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: string | null | undefined) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {
if (!setDetail) return undefined;
setDetail(name || null);
return () => setDetail(null);
}, [setDetail, name]);
}

26
src/lib/api.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
/* Types for api.js. The response type is the caller's to name:
every endpoint sends a different body, so get<T> defaults to
unknown rather than pretending to know. */
/** Per-field messages, as admin-crud.js and feedback.js send them. */
export type FieldErrors = Record<string, string>;
export declare class ApiError extends Error {
status?: number;
fields?: FieldErrors;
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
}
export declare function get<T = unknown>(
path: string,
options?: { ttl?: number; fallback?: T },
): Promise<T>;
export declare function invalidate(path?: string): void;
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
/* A 204 comes back as null. */
export declare function del<T = null>(path: string): Promise<T>;

114
src/lib/api.js Normal file
View file

@ -0,0 +1,114 @@
/* ═══════════════════════════════════════════════════════════════
API CLIENT
One place that knows how to talk to the server, so components
never call fetch directly and swapping the transport later is a
single-file change.
Two things worth knowing about the design:
The cache is a module-level Map of in-flight and settled
promises. Two components asking for /events during the same
render pass share one request, and a remount inside the TTL
costs nothing. It resets on page load, which is the right
lifetime for content that changes weekly.
Every reader can pass a `fallback`. If the request fails, that
value is used instead. This is what keeps the Figma preview
working: pass the old static module as the fallback and the
preview renders real content with no server in sight.
═══════════════════════════════════════════════════════════════ */
const BASE = import.meta.env?.VITE_API_BASE ?? "/api";
const DEFAULT_TTL = 60_000;
const cache = new Map(); // path → { at, promise }
export class ApiError extends Error {
constructor(message, { status, fields } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.fields = fields;
}
}
async function request(path, options = {}) {
const response = await fetch(`${BASE}${path}`, {
headers: { Accept: "application/json", ...options.headers },
...options,
});
if (response.status === 204) return null;
const type = response.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
// Usually the SPA fallback returning index.html for a URL the
// API doesn't serve. Parsing it would throw something useless.
throw new ApiError("Server did not return JSON.", {
status: response.status,
});
}
const body = await response.json();
if (!response.ok) {
throw new ApiError(body.error ?? "Request failed.", {
status: response.status,
fields: body.fields,
});
}
return body;
}
/* ── Reads ─────────────────────────────────────────────────────
get("/events") → cached for 60s
get("/events", { ttl: 0 }) → always fresh
get("/events", { fallback }) → fallback on any failure
───────────────────────────────────────────────────────────── */
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < ttl) return hit.promise;
const promise = request(path).catch((err) => {
cache.delete(path); // a failure shouldn't be cached
if (fallback !== undefined) {
console.warn(`api: ${path} failed, using fallback`, err);
return fallback;
}
throw err;
});
cache.set(path, { at: Date.now(), promise });
return promise;
}
export function invalidate(path) {
if (path) cache.delete(path);
else cache.clear();
}
/* ── Writes ──────────────────────────────────────────────────── */
export function post(path, data) {
return request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
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" });
}

117
src/lib/auth.tsx Normal file
View file

@ -0,0 +1,117 @@
/* ═══════════════════════════════════════════════════════════════
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, type ReactNode } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
import type { Role } from "./roles.ts";
/* What currentUser() in server/src/auth.js returns, and what
/auth/me and /auth/login send under `user`. */
export type AdminUser = {
id: number;
email: string;
name: string | null;
role: Role;
};
type AuthResponse = { user: AdminUser };
type AuthValue = {
user: AdminUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<AdminUser>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
get<AuthResponse>("/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: string, password: string) => {
const data = await post<AuthResponse>("/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: unknown): boolean {
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
View 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
View 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
View file

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

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

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

81
src/lib/media.ts Normal file
View 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
View file

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

122
src/lib/sections.tsx Normal file
View file

@ -0,0 +1,122 @@
import { useState, type ComponentType, type ReactNode } from "react";
/* ═══════════════════════════════════════════════════════════════
SECTION MANIFEST
A page is a list of sections, and a section is a view over data
the database already holds, narrowed by a filter. The map of
chapters and the vertical list of regions are the same rows seen
two ways; the three bands of retreats are one view seen three
times with a different filter each.
So a page declares what it wants and this turns it into what
PageShell takes. Every entry looks like:
{
id, title, blurb, accent, background, // the heading
Component, // the view
props: { ... }, // the filter
views: { // optional toggle
options: ["carousel", "grid"],
default: "carousel",
Toggle: EventCardsToggle,
},
}
Sections fetch their own data. The page never does, which is why
there's no loading or error state here — each view handles its
own, and one section failing doesn't blank the page.
Every Component receives `accent` so its empty and error notices
match the heading above them, and `view` when the entry declares
a toggle. Both are ignored harmlessly by a section that doesn't
want them.
═══════════════════════════════════════════════════════════════ */
/* What every section Component is handed on top of its own props. */
export type SectionInjected = { accent: string; view?: string };
export type SectionToggleProps = {
view: string;
setView: (value: string) => void;
accent: string;
options: string[];
};
export type SectionViews = {
options: string[];
default?: string;
Toggle: ComponentType<SectionToggleProps>;
};
export type SectionHeading = {
id: string;
title: string;
blurb?: string;
accent: string;
background: string;
};
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
Component: ComponentType<P & SectionInjected>;
/* The Component decides P; props are checked against it. */
props?: NoInfer<P>;
views?: SectionViews;
};
/* A manifest mixes components with different props, which no single
array element type can check. This checks each entry against its
own Component where it's written, then forgets P so the entries
fit one array. */
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
return entry as unknown as SectionEntry;
}
export type ManifestSection = SectionHeading & {
actions?: ReactNode;
content: ReactNode;
};
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
// One entry per section that has a toggle, seeded from its
// declared default so the control is right before anything loads.
const [views, setViews] = useState<Record<string, string | null>>(() =>
Object.fromEntries(
manifest
.filter(entry => entry.views)
.map(entry => [
entry.id,
entry.views?.default ?? entry.views?.options[0] ?? null,
]),
),
);
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
return manifest.map(entry => {
const { Component, props, views: spec, ...heading } = entry;
const view = views[entry.id] ?? undefined;
const Toggle = spec?.Toggle;
return {
...heading,
actions: Toggle ? (
<Toggle
view={view ?? ""}
setView={value => setView(entry.id, value)}
accent={entry.accent}
options={spec.options}
/>
) : undefined,
content: (
<Component
{...(props ?? {})}
accent={entry.accent}
{...(spec ? { view } : {})}
/>
),
};
});
}

394
src/lib/timeline.ts Normal file
View 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
View file

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

338
src/lib/useContent.ts Normal file
View 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
/** Which band of the Retreats page this belongs to. */
section_id: string
/** What kind of gathering it is. Orthogonal to section_id. */
event_type: EventType
title: string
theme?: string | null
tagline?: string | null
starts_on?: string | null
ends_on?: string | null
date_label?: string | null
status: 'upcoming' | 'past' | 'cancelled'
/** 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_sections row, as GET /events sends it beside the list. */
export type EventSection = { id: string; name: string; sort_order: number }
export type EventsResponse = { sections: EventSection[]; 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
View 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
View file

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

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

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

52
src/lib/useResource.js Normal file
View file

@ -0,0 +1,52 @@
/* ═══════════════════════════════════════════════════════════════
useResource
The read hook every page uses:
const { data, error, loading } = useResource("/events", {
fallback: { events: EVENTS }, // the old static module
});
Deliberately small. If the site ever needs mutation, refetch on
focus, or pagination, that's the point to reach for TanStack
Query rather than growing this file.
Note the `ignore` flag rather than an AbortController: the
request is shared and cached, so cancelling it would throw away
work another component may still want. We just stop writing
state after unmount.
═══════════════════════════════════════════════════════════════ */
import { useEffect, useState } from "react";
import { get } from "./api.js";
export function useResource(path, { ttl, fallback } = {}) {
// Seed with the fallback so the first paint has content when one
// is available, rather than flashing a spinner and then the same
// data a moment later.
const [state, setState] = useState(() => ({
data: fallback,
error: null,
loading: true,
}));
useEffect(() => {
let ignore = false;
setState((prev) => ({ ...prev, loading: true, error: null }));
get(path, { ttl, fallback })
.then((data) => {
if (!ignore) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (!ignore) setState((prev) => ({ ...prev, error, loading: false }));
});
return () => {
ignore = true;
};
}, [path, ttl]); // fallback is intentionally not a dependency
return state;
}

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

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

20
src/navConfig.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
/* Types for navConfig.js. */
export type PageLink = { label: string; path: string };
export type PageSectionLink = { label: string; hash: string };
export type NavAction = {
label: string;
to: string;
/** Picks the styling, not the destination. */
variant: "ghost" | "fancy";
external?: boolean;
};
export declare const PAGE_LINKS: PageLink[];
/** Keyed by the owning page's path. */
export declare const PAGE_SECTIONS: Record<string, PageSectionLink[]>;
export declare const NAV_ACTIONS: NavAction[];

56
src/navConfig.js Normal file
View file

@ -0,0 +1,56 @@
// Single source of truth for navigation.
// Add a page here and in App.jsx, and both tiers of the nav update.
export const PAGE_LINKS = [
{ label: "Home", path: "/" },
{ label: "Retreats", path: "/retreats" },
{ label: "Community", path: "/community" },
{ label: "Leadership", path: "/leadership" },
{ label: "Resources", path: "/resources" },
{ label: "History", path: "/history" },
];
// Sections belong to a page, keyed by that page's path.
// A page with no sections gets no subnav row.
export const PAGE_SECTIONS = {
"/": [
{ label: "#About", hash: "#about" },
{ label: "#Events", hash: "#events" },
{ label: "#Connect", hash: "#connect" },
],
"/retreats": [
{ label: "National", hash: "#national" },
{ label: "Regional", hash: "#regional" },
{ label: "Partner Events", hash: "#partner"},
],
"/community": [
{ label: "Local Chapters", hash: "#local" },
{ label: "Virtual Community", hash: "#virtual" },
{ label: "Partner Organizations", hash: "#partners" },
],
"/leadership": [
{ label: "Board & Staff", hash: "#board-staff" },
{ label: "Retreat Team", hash: "#retreat-team" },
{ label: "Committees & Volunteers", hash: "#committees" },
{ label: "Contact Us", hash: "#contact" },
],
"/resources": [
{ label: "Unity Teachings", hash: "#unity" },
{ label: "Branding & Marketing", hash: "#branding" },
{ label: "Partner Resources", hash: "#partner" },
],
"/history": [
{ label: "Timeline", hash: "#timeline" },
],
};
// Right-side actions. `variant` picks the styling, not the destination.
export const NAV_ACTIONS = [
{ label: "Contact Us", to: "/leadership#contact", variant: "ghost" },
{
label: "Give",
to: "/give",
variant: "fancy",
external: true,
},
];

Some files were not shown because too many files have changed in this diff Show more