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