diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js
index 79d6c34..732d154 100644
--- a/server/src/admin-crud.js
+++ b/server/src/admin-crud.js
@@ -249,6 +249,12 @@ export function normalizeId(entity, id) {
}
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
@@ -366,6 +372,12 @@ export function updateRow(db, entity, rawId, payload) {
}
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),
diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js
index 0eb4b4a..e457bb1 100644
--- a/server/src/admin-schema.js
+++ b/server/src/admin-schema.js
@@ -13,6 +13,8 @@
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
@@ -650,7 +652,133 @@ const timeline = {
],
};
-export const ENTITIES = { organizations, events, people, teams, awards, timeline };
+/* ── 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 ────────────────────── */
diff --git a/server/src/index.js b/server/src/index.js
index 172e523..6eab6ca 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -17,6 +17,7 @@ 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";
@@ -56,6 +57,7 @@ app.get("/api/health", (c) =>
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 }));
diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql
new file mode 100644
index 0000000..7be2891
--- /dev/null
+++ b/server/src/migrations/017_front_page.sql
@@ -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';
diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql
new file mode 100644
index 0000000..ce48564
--- /dev/null
+++ b/server/src/migrations/018_front_page_touch.sql
@@ -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;
diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql
new file mode 100644
index 0000000..a6987b5
--- /dev/null
+++ b/server/src/migrations/019_front_page_calendar.sql
@@ -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');
diff --git a/server/src/routes/content.js b/server/src/routes/content.js
index 02a860e..2138905 100644
--- a/server/src/routes/content.js
+++ b/server/src/routes/content.js
@@ -47,7 +47,14 @@
import { Hono } from "hono";
-import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
+import {
+ asBool,
+ loadBlocks,
+ loadLinks,
+ paragraphs,
+ shapeSeries,
+ splitLinks,
+} from "../shape.js";
const content = new Hono();
@@ -108,25 +115,6 @@ function shapeHost(row) {
};
}
-/* 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. */
-const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
-
-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,
- };
-}
-
function shapeEvent(row, links, cardBlocks, hosts = []) {
const { actions, instagram } = splitLinks(links);
diff --git a/server/src/routes/home.js b/server/src/routes/home.js
new file mode 100644
index 0000000..bbf14c4
--- /dev/null
+++ b/server/src/routes/home.js
@@ -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;
diff --git a/server/src/routes/people.js b/server/src/routes/people.js
index e89ebca..dbc31d9 100644
--- a/server/src/routes/people.js
+++ b/server/src/routes/people.js
@@ -3,6 +3,7 @@
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
@@ -20,7 +21,7 @@
import { Hono } from "hono";
-import { asBool } from "../shape.js";
+import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
const people = new Hono();
@@ -138,4 +139,161 @@ people.get("/people", (c) => {
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;
diff --git a/server/src/shape.js b/server/src/shape.js
index 797db14..246d014 100644
--- a/server/src/shape.js
+++ b/server/src/shape.js
@@ -144,4 +144,26 @@ export function splitLinks(links = []) {
};
}
+/* ── Event series ──────────────────────────────────────────────
+ The repeating schedule, or null for a one-off. Weekdays
+ collapse from seven flags to a list of the ticked ones, Sunday
+ first; an empty list means "starts_on's weekday", which the
+ client resolves since it already holds starts_on. Occurrences
+ are not sent — they are derived, and the client derives them
+ against its own today. Shared by /events and /front-page.
+ ───────────────────────────────────────────────────────────── */
+const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
+
+export function shapeSeries(row) {
+ if (!asBool(row.is_series)) return null;
+ return {
+ frequency: row.series_frequency,
+ interval: row.series_interval,
+ weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])),
+ start_time: row.series_start_time,
+ end_time: row.series_end_time,
+ count: row.series_count,
+ };
+}
+
export { asBool };
diff --git a/src/App.tsx b/src/App.tsx
index 9b7efd4..ff85789 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,6 +23,7 @@ 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";
@@ -54,6 +55,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/DoveMark.tsx b/src/components/DoveMark.tsx
new file mode 100644
index 0000000..675a5a7
--- /dev/null
+++ b/src/components/DoveMark.tsx
@@ -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