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>
This commit is contained in:
Zaldimmar 2026-09-25 05:37:42 -05:00
parent b4b013209b
commit 6a69084de2
25 changed files with 2299 additions and 494 deletions

View file

@ -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),

View file

@ -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", "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 ────────────────────── */

View file

@ -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 }));

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

@ -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);

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;

View file

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