From 6ec2fb240a40b7c314b60f00bf3a4efc0e2e4e4c Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 05:05:45 -0500 Subject: [PATCH 1/4] Add person detail page and link people tiles to it GET /api/people/:id returns a published person's profile, public roles (current and past), published events they were billed at or hosted, and public awards, each under the same visibility rules its own page applies. public_phone is never sent. PersonDetail renders it at /people/:id, the route personHref already pointed at. PeopleTiles links a tile with a people id to that page; an expandable tile keeps its details panel and the panel carries a "View full profile" link instead. Co-Authored-By: Claude Opus 5.5 --- server/src/routes/people.js | 160 ++++++++++++++- src/App.tsx | 2 + src/components/PeopleTiles.css | 32 ++- src/components/PeopleTiles.tsx | 32 ++- src/lib/useContent.ts | 60 ++++++ src/pages/PersonDetail.tsx | 363 +++++++++++++++++++++++++++++++++ 6 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 src/pages/PersonDetail.tsx diff --git a/server/src/routes/people.js b/server/src/routes/people.js index e89ebca..dbc31d9 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,6 +3,7 @@ GET /teams/:id/people current public members of a team GET /people?ids=a,b,c named people, any order + GET /people/:id one person's page The team route reads v_org_leadership, which already decides who counts as current and public — affiliation still open, marked @@ -20,7 +21,7 @@ import { Hono } from "hono"; -import { asBool } from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const people = new Hono(); @@ -138,4 +139,161 @@ people.get("/people", (c) => { return json(c, { people: rows.map(shapePerson) }); }); +/* ── One person's page ───────────────────────────────────────── + Everything public that points at this person, each list with + the same visibility rules its own page applies: a role needs a + public affiliation and a published organization, an event must + be published, an award must be published and the citation + public. A hidden team drops its name rather than the role — + the seat is still real, it just has no page to link to. + + Roles are current and past. v_org_leadership only knows + current, which is what a roster wants and not what a person's + record does, so this reads affiliations directly. + + Events merge two tables: event_people (who was billed, and as + what) and event_hosts (who ran it). One person can be both at + one event, so they collapse to one row carrying every role. + ───────────────────────────────────────────────────────────── */ + +people.get("/people/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name, + o.kind AS primary_org_kind + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 + WHERE p.id = ? AND p.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such person" }, 404); + + const links = loadLinks(db, "person", [id]).get(id) ?? []; + const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; + const { actions, socials, website, instagram } = splitLinks(links); + + // Current first, then most recently ended. Within each, the same + // order a roster uses: owner, then the affiliation's sort_order. + const roles = db + .prepare( + `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, + o.id AS org_id, o.name AS org_name, o.kind AS org_kind, + t.id AS team_id, t.name AS team_name + FROM affiliations a + JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 + WHERE a.person_id = ? AND a.is_public = 1 + ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, + a.is_owner DESC, a.sort_order, o.sort_order`, + ) + .all(id) + .map((r) => ({ + title: r.title, + role: r.role, + is_owner: asBool(r.is_owner), + started_on: r.started_on, + ended_on: r.ended_on, + org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, + team: r.team_id ? { id: r.team_id, name: r.team_name } : null, + })); + + const eventRows = db + .prepare( + `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, + e.effective_status AS status, x.role, x.title AS billing + FROM ( + SELECT event_id, role, title, sort_order + FROM event_people + WHERE person_id = ? AND is_public = 1 + UNION ALL + SELECT event_id, 'host', NULL, -1 + FROM event_hosts + WHERE person_id = ? + ) x + JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 + ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + export default people; diff --git a/src/App.tsx b/src/App.tsx index 9b7efd4..ff85789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import EventDetail from './pages/EventDetail.tsx' import OrganizationDetail from './pages/OrganizationDetail.tsx' import TeamDetail from './pages/TeamDetail.tsx' import AwardDetail from './pages/AwardDetail.tsx' +import PersonDetail from './pages/PersonDetail.tsx' /*Admin Pages*/ import AdminLayout from "./pages/admin/AdminLayout.tsx"; @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index 2f70d5d..ecff0d2 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,10 +123,16 @@ text-align: inherit; } -.pl__tile--button { +.pl__tile--button, +.pl__tile--link { cursor: pointer; } +.pl__tile--link { + color: inherit; + text-decoration: none; +} + .pl__frame { position: relative; display: flex; @@ -150,16 +156,20 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:hover .pl__frame, +.pl__tile--link:focus-visible .pl__frame { transform: translateY(-2px); box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); } -.pl__tile--button:focus-visible { +.pl__tile--button:focus-visible, +.pl__tile--link:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -311,6 +321,20 @@ max-width: 62ch; } +.pl__profile { + display: inline-block; + margin-top: 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--pl-accent); + text-decoration: none; +} + +.pl__profile:hover, +.pl__profile:focus-visible { + text-decoration: underline; +} + .pl__empty { margin: 0; font-size: 0.9375rem; diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index bd738a9..62dcf4f 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -8,7 +8,10 @@ import { type HTMLAttributes, } from "react"; +import { Link } from "react-router-dom"; + import { get } from "../lib/api.js"; +import { isBadId, personHref } from "../lib/hrefs.ts"; import "./PeopleTiles.css"; /** @@ -43,6 +46,13 @@ import "./PeopleTiles.css"; * * Field names follow the API (is_owner, location_label), so a row * from /api/teams/:id/people drops in unchanged. + * + * Profiles + * A person with a string id is taken to be a people row and links + * to /people/:id. A tile with nothing to expand is that link; an + * expandable one stays the button that opens its panel — a link + * can't sit inside a button — and the panel carries the link + * instead. A hand-written entry with no id links nowhere. */ export interface Person { @@ -551,7 +561,14 @@ function Tile({ ); if (!expandable) { - return
{content}
; + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); } return ( @@ -610,6 +627,7 @@ function DetailPanel({ const title = titleOf(person); const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; const tint = person.accent || group?.accent; + const profile = profileHref(person); return (
))} + + {profile && ( + + View full profile → + + )}
); } @@ -693,6 +717,12 @@ function Chevron() { /* ── Helpers ─────────────────────────────────────────────────── */ +/* A string id is a people slug; a numeric or missing one is a + hand-written entry with no page behind it. */ +function profileHref(person: Person): string | null { + return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null; +} + function keyFor(group: PeopleGroup, person: Person, index: number): string { return `${group.id}:${person.id ?? person.name ?? index}`; } diff --git a/src/lib/useContent.ts b/src/lib/useContent.ts index 025dc94..ddf380e 100644 --- a/src/lib/useContent.ts +++ b/src/lib/useContent.ts @@ -276,3 +276,63 @@ export type AwardRecord = { export const useAward = (id?: string): Resource => useRecord(detailPath('/awards', id), 'award') + +/* ── People ──────────────────────────────────────────────────── */ + +/** One public affiliation. ended_on null means current. */ +export type PersonRole = { + title?: string | null + role: 'lead' | 'board' | 'staff' | 'volunteer' | 'member' + is_owner: boolean + started_on?: string | null + ended_on?: string | null + org: OrgRef + /** Null when there's no team, or the team is unpublished. */ + team: { id: string; name: string } | null +} + +/** A published event this person was billed at or hosted, with + * every capacity they appeared in. 'host' comes from event_hosts. */ +export type PersonEvent = { + id: string + title: string + event_type: EventType + date_label?: string | null + starts_on?: string | null + status: 'upcoming' | 'past' | 'cancelled' + roles: { role: string; title?: string | null }[] +} + +export type PersonAward = { + award: { id: string; name: string; logo?: string | null } + awarded_on?: string | null + citation?: string | null + event: { id: string; title: string } | null +} + +export type PersonRecord = { + id: string + name: string + pronouns?: string | null + tagline?: string | null + photo?: string | null + location_label?: string | null + public_email?: string | null + /** The primary organization, when it's published. */ + org: OrgRef | null + /** people.bio split on blank lines. */ + bio: string[] + description: string[] + blocks: ContentBlock[] + links: Link[] + socials: Link[] + website?: string | null + instagram?: string | null + /** Current first, then most recently ended. */ + roles: PersonRole[] + events: PersonEvent[] + awards: PersonAward[] +} + +export const usePerson = (id?: string): Resource => + useRecord(detailPath('/people', id), 'person') diff --git a/src/pages/PersonDetail.tsx b/src/pages/PersonDetail.tsx new file mode 100644 index 0000000..ecf4e55 --- /dev/null +++ b/src/pages/PersonDetail.tsx @@ -0,0 +1,363 @@ +/* ═══════════════════════════════════════════════════════════════ + PERSON DETAIL — /people/:id + + Everything public that points at one person, gathered by + GET /people/:id in people.js. Visibility is decided there, the + same way each list's own page decides it, so nothing here + filters. + + Roles are current and past. A roster only ever wants who holds + a seat now; a person's record is also where "served on the board + 2018–2022" belongs, so ended affiliations list under Previously. + + public_phone is never sent. The email is the one contact detail + the page offers. + + Sections after About alternate background in the order they + appear, so a person with no roles doesn't get two tinted bands + in a row. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, useParams } from 'react-router-dom' + +import PageShell, { type ShellSection } from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import ContentBlocks from '../components/ContentBlocks.tsx' +import { + usePerson, + type PersonEvent, + type PersonRecord, + type PersonRole, +} from '../lib/useContent.ts' +import { awardHref, eventHref, orgHref, teamHref } from '../lib/hrefs.ts' +import { initials, personPhoto } from '../lib/media.ts' +import { eventTypeLabel } from '../lib/eventTypes.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' +const BACKGROUNDS = ['#ffffff', '#eef9fb'] + +/* affiliations.role, for a row with no title of its own. */ +const ROLE_LABEL: Record = { + lead: 'Lead', + board: 'Board member', + staff: 'Staff', + volunteer: 'Volunteer', + member: 'Member', +} + +const capitalize = (word: string) => + word ? word.charAt(0).toUpperCase() + word.slice(1) : '' + +export default function PersonDetail() { + const { id } = useParams() + const { data: person, loading, error, notFound, reload } = usePerson(id) + + if (!person) { + return ( + + ) + } + + const accent = TEAL + const current = person.roles.filter((role) => !role.ended_on) + const previous = person.roles.filter((role) => role.ended_on) + + const sections: ShellSection[] = [ + { + id: 'about', + title: 'About', + accent, + background: BACKGROUNDS[0], + content: ( +
+ + + {[...person.bio, ...person.description].map((paragraph, index) => ( +

+ {paragraph} +

+ ))} + +
+ +
+ + +
+ ), + }, + ] + + if (person.roles.length > 0) { + sections.push({ + id: 'roles', + title: 'Roles', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+ {current.length > 0 && } + + {previous.length > 0 && ( +
+ {current.length > 0 && ( +

+ Previously +

+ )} + +
+ )} +
+ ), + }) + } + + if (person.events.length > 0) { + sections.push({ + id: 'events', + title: 'Events', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+
    + {person.events.map((event) => ( +
  • + +
  • + ))} +
+
+ ), + }) + } + + if (person.awards.length > 0) { + sections.push({ + id: 'awards', + title: 'Awards', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+ {person.awards.map((entry, index) => ( +
+

+ + {entry.award.name} + +

+ {(entry.awarded_on || entry.event) && ( +

+ {year(entry.awarded_on)} + {entry.event && ( + <> + {entry.awarded_on && ' · '} + + {entry.event.title} + + + )} +

+ )} + {entry.citation && ( +

+ {entry.citation} +

+ )} +
+ ))} +
+ ), + }) + } + + return ( + + ) +} + +/* ── The strip of facts under the heading ────────────────────── */ + +function Facts({ person, accent }: { person: PersonRecord; accent: string }) { + const photo = personPhoto(person.photo) + + return ( +
+ {photo ? ( + + ) : ( + + )} + + {person.pronouns && {person.pronouns}} + + {person.org && ( + + {person.org.name} + + )} + + {person.location_label && {person.location_label}} + + + Leadership + +
+ ) +} + +function Contact({ person, accent }: { person: PersonRecord; accent: string }) { + const outlined = [ + person.website ? { url: person.website, label: 'Website' } : null, + person.public_email + ? { url: `mailto:${person.public_email}`, label: person.public_email } + : null, + ...person.socials, + ].filter((link): link is NonNullable => Boolean(link)) + + if (person.links.length === 0 && outlined.length === 0) return null + + return ( +
+ {person.links.map((link) => ( + + {link.label} + + ))} + + {outlined.map((link) => ( + + {link.label} + + ))} +
+ ) +} + +/* ── Roles ───────────────────────────────────────────────────── */ + +function RoleList({ roles, accent }: { roles: PersonRole[]; accent: string }) { + return ( +
    + {roles.map((role, index) => ( +
  • +

    + {role.title || ROLE_LABEL[role.role] || capitalize(role.role)} +

    +

    + {role.team && ( + <> + + {role.team.name} + + {' · '} + + )} + + {role.org.name} + +

    + {tenure(role) && ( +

    + {tenure(role)} +

    + )} +
  • + ))} +
+ ) +} + +/* "2018 – 2022", "Since 2021", "Until 2019", or null. Years only: + affiliation dates are often backfilled from memory. */ +function tenure(role: PersonRole): string | null { + const from = year(role.started_on) + const to = year(role.ended_on) + if (from && to) return from === to ? from : `${from} – ${to}` + if (from) return `Since ${from}` + if (to) return `Until ${to}` + return null +} + +/* ── Events ──────────────────────────────────────────────────── */ + +function EventRow({ event, accent }: { event: PersonEvent; accent: string }) { + const when = event.date_label || year(event.starts_on) + const capacities = event.roles + .map((role) => role.title || capitalize(role.role)) + .join(', ') + + return ( +
+

+ + {event.title} + +

+

+ {[capacities, eventTypeLabel(event.event_type), when].filter(Boolean).join(' · ')} +

+
+ ) +} + +/* Dates here may be partial ('2019', '2019-06'), so take the + leading year rather than parsing. */ +function year(date?: string | null): string | null { + if (!date) return null + const match = /^(\d{4})/.exec(date) + return match ? match[1] : date +} From 6a69084de2ff68de7b7bd98653ac36e61fe9e75d Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 05:37:42 -0500 Subject: [PATCH 2/4] 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