diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js
index 732d154..d0fe17d 100644
--- a/server/src/admin-crud.js
+++ b/server/src/admin-crud.js
@@ -34,7 +34,6 @@ export class HttpError extends Error {
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. */
@@ -99,13 +98,6 @@ function coerceValue(column, raw, errors, prefix = "") {
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
return ISO_DATE.test(value) ? value : null;
}
- case "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;
@@ -249,12 +241,6 @@ 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
@@ -372,12 +358,6 @@ 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 e457bb1..f1ce448 100644
--- a/server/src/admin-schema.js
+++ b/server/src/admin-schema.js
@@ -13,8 +13,6 @@
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
@@ -42,7 +40,6 @@ 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",
@@ -284,10 +281,6 @@ const organizations = {
/* ── 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",
@@ -346,18 +339,6 @@ const events = {
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")],
@@ -652,133 +633,7 @@ const 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,
-};
+export const ENTITIES = { organizations, events, people, teams, awards, timeline };
/* ── Options for the form's select inputs ────────────────────── */
diff --git a/server/src/index.js b/server/src/index.js
index 6eab6ca..172e523 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -17,7 +17,6 @@ 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";
@@ -57,7 +56,6 @@ 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/016_event-series.sql b/server/src/migrations/016_event-series.sql
deleted file mode 100644
index b766eb1..0000000
--- a/server/src/migrations/016_event-series.sql
+++ /dev/null
@@ -1,73 +0,0 @@
--- ═══════════════════════════════════════════════════════════════
--- 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);
diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql
deleted file mode 100644
index 7be2891..0000000
--- a/server/src/migrations/017_front_page.sql
+++ /dev/null
@@ -1,189 +0,0 @@
--- ═══════════════════════════════════════════════════════════════
--- 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
deleted file mode 100644
index ce48564..0000000
--- a/server/src/migrations/018_front_page_touch.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- ═══════════════════════════════════════════════════════════════
--- 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
deleted file mode 100644
index a6987b5..0000000
--- a/server/src/migrations/019_front_page_calendar.sql
+++ /dev/null
@@ -1,61 +0,0 @@
--- ═══════════════════════════════════════════════════════════════
--- 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 2138905..87aab06 100644
--- a/server/src/routes/content.js
+++ b/server/src/routes/content.js
@@ -47,14 +47,7 @@
import { Hono } from "hono";
-import {
- asBool,
- loadBlocks,
- loadLinks,
- paragraphs,
- shapeSeries,
- splitLinks,
-} from "../shape.js";
+import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
const content = new Hono();
@@ -131,7 +124,6 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
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,
diff --git a/server/src/routes/home.js b/server/src/routes/home.js
deleted file mode 100644
index bbf14c4..0000000
--- a/server/src/routes/home.js
+++ /dev/null
@@ -1,186 +0,0 @@
-/* ═══════════════════════════════════════════════════════════════
- 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 dbc31d9..e89ebca 100644
--- a/server/src/routes/people.js
+++ b/server/src/routes/people.js
@@ -3,7 +3,6 @@
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
@@ -21,7 +20,7 @@
import { Hono } from "hono";
-import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
+import { asBool } from "../shape.js";
const people = new Hono();
@@ -139,161 +138,4 @@ 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 246d014..797db14 100644
--- a/server/src/shape.js
+++ b/server/src/shape.js
@@ -144,26 +144,4 @@ 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 ff85789..9b7efd4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,7 +23,6 @@ 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";
@@ -55,7 +54,6 @@ export default function App() {
} />
} />
} />
- } />
} />
} />
} />
diff --git a/src/components/DoveMark.tsx b/src/components/DoveMark.tsx
deleted file mode 100644
index 675a5a7..0000000
--- a/src/components/DoveMark.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-/* ═══════════════════════════════════════════════════════════════
- 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