From 6a69084de2ff68de7b7bd98653ac36e61fe9e75d Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 05:37:42 -0500 Subject: [PATCH] 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 --- server/src/admin-crud.js | 12 + server/src/admin-schema.js | 130 +++- server/src/index.js | 2 + server/src/migrations/017_front_page.sql | 189 ++++++ .../src/migrations/018_front_page_touch.sql | 16 + server/src/routes/content.js | 28 +- server/src/routes/home.js | 186 ++++++ server/src/shape.js | 22 + src/lib/adminSchema.d.ts | 6 +- src/lib/adminSchema.js | 196 +++++- src/lib/embeds.ts | 62 ++ src/lib/media.ts | 4 + src/lib/useFrontPage.ts | 89 +++ src/pages/Home.tsx | 572 ++++-------------- src/pages/admin/EntityEdit.tsx | 20 +- src/pages/admin/EntityList.tsx | 9 +- src/pages/admin/adminNav.js | 6 + .../sections/home/FeaturedTimelineRail.tsx | 250 ++++++++ src/pages/sections/home/HeroStage.tsx | 307 ++++++++++ src/pages/sections/home/HomeLink.tsx | 45 ++ .../sections/home/NextEventCountdown.tsx | 133 ++++ src/pages/sections/home/Pathfinder.tsx | 186 ++++++ src/pages/sections/home/RetreatsBand.tsx | 40 ++ src/pages/sections/home/StatsBand.tsx | 127 ++++ src/pages/sections/home/home.css | 156 +++++ 25 files changed, 2299 insertions(+), 494 deletions(-) create mode 100644 server/src/migrations/017_front_page.sql create mode 100644 server/src/migrations/018_front_page_touch.sql create mode 100644 server/src/routes/home.js create mode 100644 src/lib/embeds.ts create mode 100644 src/lib/useFrontPage.ts create mode 100644 src/pages/sections/home/FeaturedTimelineRail.tsx create mode 100644 src/pages/sections/home/HeroStage.tsx create mode 100644 src/pages/sections/home/HomeLink.tsx create mode 100644 src/pages/sections/home/NextEventCountdown.tsx create mode 100644 src/pages/sections/home/Pathfinder.tsx create mode 100644 src/pages/sections/home/RetreatsBand.tsx create mode 100644 src/pages/sections/home/StatsBand.tsx create mode 100644 src/pages/sections/home/home.css 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..85270ec 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", "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/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/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/lib/adminSchema.d.ts b/src/lib/adminSchema.d.ts index 39658f8..d6344ae 100644 --- a/src/lib/adminSchema.d.ts +++ b/src/lib/adminSchema.d.ts @@ -94,6 +94,9 @@ export type EntitySpec = { 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; @@ -108,7 +111,8 @@ export type AdminEntityKey = | "people" | "teams" | "awards" - | "timeline"; + | "timeline" + | "front_page"; /* Indexed by route param as often as by name, so any other string reads as possibly missing. */ diff --git a/src/lib/adminSchema.js b/src/lib/adminSchema.js index ae29611..426bc2c 100644 --- a/src/lib/adminSchema.js +++ b/src/lib/adminSchema.js @@ -941,7 +941,201 @@ const timeline = { ], }; -export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline }; +/* ── Front page ────────────────────────────────────────────────── + + One record (see the singleton note in server/src/admin-schema.js). + The groups are the hero; the collections below are the rest of + the page. Photos and the livestream are editable whatever the + mode, so either can be ready before the switch is flipped. + + Section keys and stat sources are the CHECK lists in migration + 017. The labels here are what the admin reads; the values are + what the page and the API key on. */ + +const FRONT_PAGE_SECTIONS = [ + ["countdown", "Countdown to the next event"], + ["retreats", "National Retreats carousel"], + ["stats", "Numbers"], + ["timeline", "Featured timeline"], + ["connect", "Find your way in"], +]; + +const STAT_SOURCES = [ + ["manual", "Typed in — shows Value"], + ["years_since", "Years since — Value is the year"], + ["regions", "Count: published regions"], + ["chapters", "Count: published chapters"], + ["partners", "Count: published partners"], + ["events_held", "Count: past events"], + ["retreats_held", "Count: past retreats"], + ["people", "Count: published people"], + ["awards_given", "Count: public awards given"], +]; + +const frontPage = { + key: "front_page", + label: "Front page", + singular: "front page", + idLabel: "Page", + singleton: "home", + + list: { columns: [], filters: [] }, + + groups: [ + { + legend: "Hero", + note: "The first thing a visitor sees. Blank buttons don't render.", + fields: [ + { + path: "hero_mode", + label: "Mode", + widget: "select", + options: [ + ["brand", "Brand — animated colour, no media"], + ["photos", "Photos — cycles through the photos below"], + ["livestream", "Livestream — embeds the stream with a LIVE badge"], + ], + blankLabel: "— brand —", + help: "Switch to Livestream when you go live, and back when you're done", + }, + { path: "eyebrow", label: "Eyebrow", help: "Small line above the headline" }, + { path: "headline", label: "Headline", full: true }, + { path: "subhead", label: "Subhead", widget: "textarea", full: true }, + { path: "primary_label", label: "Main button label" }, + { path: "primary_url", label: "Main button link", help: "/retreats, #connect or a full URL" }, + { path: "secondary_label", label: "Second button label" }, + { path: "secondary_url", label: "Second button link" }, + ], + }, + { + legend: "Photos and livestream", + fields: [ + { + path: "slide_seconds", + label: "Seconds per photo", + widget: "number", + help: "Photos mode. 3 to 60", + }, + { + path: "livestream_url", + label: "Livestream link", + full: true, + help: "A YouTube, Facebook or Vimeo link, or any embed URL", + }, + { path: "livestream_title", label: "Livestream title", full: true }, + ], + }, + { + legend: "Countdown", + fields: [ + { + path: "countdown_event_id", + label: "Count down to", + widget: "select", + optionsFrom: "events", + blankLabel: "— the next upcoming event —", + help: "Leave blank almost always. A pinned event that's over falls back to the next one", + }, + ], + }, + ], + + children: [ + { + key: "sections", + label: "Page sections", + note: "Drag to reorder. Each section can appear once; leave one out to drop it.", + addLabel: "Add section", + title: (row) => + FRONT_PAGE_SECTIONS.find(([key]) => key === row.section)?.[1] ?? "New section", + blank: { section: "", title: "", blurb: "" }, + fields: [ + { + path: "section", + label: "Section", + widget: "select", + options: FRONT_PAGE_SECTIONS, + required: true, + }, + { path: "title", label: "Heading", help: "Blank for the section's own" }, + { path: "blurb", label: "Blurb", full: true }, + { path: "is_hidden", label: "Hidden", widget: "checkbox", help: "Hide for now" }, + ], + }, + { + key: "slides", + label: "Hero photos", + note: "Shown in Photos mode, in this order.", + addLabel: "Add photo", + title: (row) => row.caption || row.media || "New photo", + blank: { media: "", alt: "", caption: "" }, + fields: [ + { path: "media", label: "Photo", required: true, help: "Filename in public/front-page/, or a URL" }, + { path: "alt", label: "Description", help: "For screen readers" }, + { path: "caption", label: "Caption", full: true }, + { path: "link_url", label: "Link", help: "Optional; makes the caption a link" }, + ], + }, + { + key: "stats", + label: "Numbers", + note: "Counts update themselves. A typed-in number with no value is left out.", + addLabel: "Add number", + title: (row) => row.label || "New number", + blank: { label: "", source: "", value: "" }, + fields: [ + { path: "label", label: "Label", required: true }, + { + path: "source", + label: "Where it comes from", + widget: "select", + options: STAT_SOURCES, + blankLabel: "— typed in —", + }, + { path: "value", label: "Value", help: "Typed in: the number. Years since: the year" }, + { path: "suffix", label: "Suffix", help: "+, k, % …" }, + { path: "note", label: "Note", full: true }, + ], + }, + { + key: "paths", + label: "Find your way in", + note: "Each path is a choice a visitor can pick; its actions appear when they do.", + addLabel: "Add path", + title: (row) => [row.icon, row.label].filter(Boolean).join(" ") || "New path", + blank: { label: "", icon: "", blurb: "" }, + fields: [ + { path: "label", label: "Label", required: true, help: "Attend, Serve…" }, + { path: "icon", label: "Icon", help: "One emoji" }, + { path: "blurb", label: "Blurb", full: true }, + ], + children: [ + { + key: "actions", + label: "Actions", + addLabel: "Add action", + title: (row) => row.label || "New action", + blank: { label: "", url: "" }, + fields: [ + { path: "label", label: "Label", required: true }, + { path: "url", label: "Link", required: true }, + { path: "description", label: "Description", full: true }, + ], + }, + ], + }, + ], +}; + +export const ADMIN_ENTITIES = { + organizations, + events, + people, + teams, + awards, + timeline, + front_page: frontPage, +}; export function slugify(value) { return String(value ?? "") diff --git a/src/lib/embeds.ts b/src/lib/embeds.ts new file mode 100644 index 0000000..4f0f52e --- /dev/null +++ b/src/lib/embeds.ts @@ -0,0 +1,62 @@ +/* ═══════════════════════════════════════════════════════════════ + EMBEDS + + Turns the link an admin pastes into the URL an