From 5ed7994a642bbe6e1fef9da29babcaed55a66b84 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 04:46:49 -0500 Subject: [PATCH 1/5] Add recurring series to events A Series checkbox under When opens a panel for the schedule: weekly on chosen weekdays, monthly by date, or monthly by weekday position, every N weeks or months, with meeting times and an optional meeting count. starts_on anchors the schedule and ends_on bounds it, so effective_status needs no change. Occurrences are derived, not stored. src/lib/eventSeries.ts builds the schedule label shown on cards and the event page, and the upcoming dates listed on the event page. Migration 016 adds the columns; the CRUD engine gains a time type. Co-Authored-By: Claude Opus 5.5 --- server/src/admin-crud.js | 8 + server/src/admin-schema.js | 17 ++ server/src/migrations/016_event-series.sql | 73 ++++++ server/src/routes/content.js | 20 ++ src/components/admin/fields.tsx | 4 +- src/lib/adminSchema.d.ts | 3 +- src/lib/adminSchema.js | 63 +++++ src/lib/eventSeries.ts | 259 +++++++++++++++++++++ src/lib/useContent.ts | 3 + src/pages/EventDetail.tsx | 49 +++- src/pages/sections/EventList-Cards.tsx | 4 + 11 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 server/src/migrations/016_event-series.sql create mode 100644 src/lib/eventSeries.ts diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index d0fe17d..79d6c34 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -34,6 +34,7 @@ export class HttpError extends Error { const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/; /* Not a value the caller can ever send, so it can mean "leave this column out of the statement" without colliding with real data. */ @@ -98,6 +99,13 @@ function coerceValue(column, raw, errors, prefix = "") { if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD."; return ISO_DATE.test(value) ? value : null; } + case "time": { + // sends HH:MM, or HH:MM:SS when a step + // asks for seconds. Nothing here does, so seconds are dropped. + const value = String(raw).trim().slice(0, 5); + if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour."; + return CLOCK_TIME.test(value) ? value : null; + } default: { const value = String(raw).trim(); return value === "" ? null : value; diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index f1ce448..0eb4b4a 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -40,6 +40,7 @@ const int = (name, opts = {}) => ({ name, type: "int", ...opts }); const real = (name, opts = {}) => ({ name, type: "real", ...opts }); const bool = (name, opts = {}) => ({ name, type: "bool", ...opts }); const date = (name, opts = {}) => ({ name, type: "date", ...opts }); +const time = (name, opts = {}) => ({ name, type: "time", ...opts }); const enumeration = (name, values, opts = {}) => ({ name, type: "enum", @@ -281,6 +282,10 @@ const organizations = { /* ── Events ──────────────────────────────────────────────────── */ +/* Column suffixes for the series weekday flags, Sunday first to + match Date#getDay. */ +const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + const events = { key: "events", table: "events", @@ -339,6 +344,18 @@ const events = { bool("is_published"), int("sort_order"), bool("in_timeline"), + + // A repeating schedule. Columns rather than a side table: the + // schedule is always exactly one per event, and the public view + // is SELECT e.*, so it reaches the site with no join. Ignored + // while is_series is 0. See migration 016 for what each means. + bool("is_series"), + enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]), + int("series_interval"), + ...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)), + time("series_start_time"), + time("series_end_time"), + int("series_count"), ], extensions: [timelineExtension("event")], diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql new file mode 100644 index 0000000..b766eb1 --- /dev/null +++ b/server/src/migrations/016_event-series.sql @@ -0,0 +1,73 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SERIES +-- +-- An event that meets on a schedule — a weekly class, a monthly +-- meeting — is still one row. is_series says the dates repeat; the +-- series_ columns say how. Occurrences are never stored: they are +-- a pure function of these columns plus starts_on and ends_on, and +-- the site works them out when it draws them. +-- +-- The event's own dates bound the series. starts_on is the first +-- meeting and anchors everything else: which week an every-other- +-- week series is "on", which day of the month a monthly one keeps, +-- and which weekday it falls on when no day is ticked. ends_on, +-- when set, is the last day it can meet — which is also what keeps +-- effective_status in v_events right with no change to the view. +-- series_count, when set, stops it after that many meetings, +-- whichever comes first. +-- +-- series_frequency: +-- weekly on the ticked weekdays, every N weeks +-- monthly_date on starts_on's day of the month (the 13th), +-- every N months; a short month uses its last day +-- monthly_weekday on starts_on's weekday position (2nd Tuesday), +-- every N months; a 5th becomes "last" +-- +-- One boolean per weekday rather than a packed text column: each +-- is a checkbox the CRUD engine already knows how to validate and +-- write, and a CHECK can hold it to 0 or 1. +-- +-- frequency and interval are NOT NULL with defaults so that a box +-- ticked with nothing else filled in is still a complete schedule — +-- weekly, on starts_on's weekday — and so every existing row gets +-- a valid value without a backfill. They are ignored while +-- is_series is 0. +-- +-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a +-- backstop; the admin engine checks the range before it gets here. +-- +-- No change to v_events: it is SELECT e.*, so the columns arrive +-- on /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly' + CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')); + +ALTER TABLE events + ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1); + +ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)); +ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)); +ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)); +ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)); +ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)); +ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)); +ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_start_time TEXT + CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_end_time TEXT + CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_count INTEGER CHECK (series_count >= 1); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index 87aab06..02a860e 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -108,6 +108,25 @@ 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); @@ -124,6 +143,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) { ends_on: row.ends_on, date_label: row.date_label, status: row.effective_status, + series: shapeSeries(row), location_label: row.location_label, locality: row.locality, diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index c56f092..d3a761b 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -225,7 +225,9 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp ) : ( ({ + path: `series_${day}`, + label: name, + widget: "checkbox", + help: `Meets on ${name}s`, + })), + ], +}; + const events = { key: "events", label: "Events", @@ -400,8 +456,15 @@ const events = { options: ["upcoming", "past", "cancelled"], blankLabel: "— derive from end date —", }, + { + path: "is_series", + label: "Series", + widget: "checkbox", + help: "Repeats on a schedule — a weekly class, a monthly meeting", + }, ], }, + seriesGroup, { legend: "Where", fields: PLACE_FIELDS }, { legend: "Appearance", diff --git a/src/lib/eventSeries.ts b/src/lib/eventSeries.ts new file mode 100644 index 0000000..a5259d5 --- /dev/null +++ b/src/lib/eventSeries.ts @@ -0,0 +1,259 @@ +/* ═══════════════════════════════════════════════════════════════ + EVENT SERIES + + An event with is_series set meets on a schedule rather than once. + The API sends the schedule as-is (shapeSeries in content.js); this + file is the one place that turns it into words and dates, so the + cards and the detail page can't describe the same series two ways. + + Occurrences are derived, never stored. starts_on is the first + meeting and the anchor: it fixes which weeks an every-other-week + series is "on", which day a monthly one keeps, and the weekday + when none is ticked. ends_on, when set, is the last day it can + meet; count, when set, stops it after that many meetings. + + Dates are 'YYYY-MM-DD' and are handled as UTC midnights so that + stepping a day never lands on a DST gap. They are calendar dates, + not instants — nothing here converts timezones. + ═══════════════════════════════════════════════════════════════ */ + +export type SeriesFrequency = 'weekly' | 'monthly_date' | 'monthly_weekday' + +export type SeriesWeekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' + +export type EventSeries = { + frequency: SeriesFrequency + interval: number + /** Ticked days, Sunday first. Empty means starts_on's weekday. */ + weekdays: SeriesWeekday[] + /** 'HH:MM', 24-hour. */ + start_time?: string | null + end_time?: string | null + count?: number | null +} + +/** How many upcoming meetings the event page lists. */ +export const SERIES_UPCOMING_SHOWN = 6 + +/* Past this many meetings the walk stops, whatever the schedule + says. Twenty years of a daily-ish weekly series is well inside + it; an open-ended series with a start date decades back is what + it's for. */ +const MAX_OCCURRENCES = 5000 + +/* Index is Date#getUTCDay. */ +const WEEKDAYS: SeriesWeekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] +const WEEKDAY_NAMES = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] + +const DAY_MS = 86_400_000 + +/* ── Dates ───────────────────────────────────────────────────── */ + +function parseDate(value?: string | null): Date | null { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + const date = new Date(`${value}T00:00:00Z`) + return Number.isNaN(date.getTime()) ? null : date +} + +const isoDate = (date: Date) => date.toISOString().slice(0, 10) + +/* The viewer's today, as a calendar date. */ +function today(): string { + const now = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +} + +const daysInMonth = (year: number, month: number) => + new Date(Date.UTC(year, month + 1, 0)).getUTCDate() + +/* 1st–4th, or 5 for a date in the month's fifth week, which the + schedule treats as "last" — every month has a last Tuesday, not + every month has a fifth. */ +const weekOfMonth = (date: Date) => Math.ceil(date.getUTCDate() / 7) + +function nthWeekday(year: number, month: number, weekday: number, nth: number): Date { + if (nth >= 5) { + const last = new Date(Date.UTC(year, month, daysInMonth(year, month))) + const back = (last.getUTCDay() - weekday + 7) % 7 + return new Date(last.getTime() - back * DAY_MS) + } + const first = new Date(Date.UTC(year, month, 1)) + const ahead = (weekday - first.getUTCDay() + 7) % 7 + return new Date(Date.UTC(year, month, 1 + ahead + (nth - 1) * 7)) +} + +/* ── Occurrences ─────────────────────────────────────────────── */ + +/* Every meeting date in schedule order, lazily, bounded by ends_on, + count and MAX_OCCURRENCES. */ +function* occurrences( + series: EventSeries, + startsOn?: string | null, + endsOn?: string | null, +): Generator { + const start = parseDate(startsOn) + if (!start) return + + const end = parseDate(endsOn) + const limit = Math.min(series.count ?? MAX_OCCURRENCES, MAX_OCCURRENCES) + const interval = Math.max(1, series.interval || 1) + let emitted = 0 + + const within = (date: Date) => !end || date.getTime() <= end.getTime() + + if (series.frequency === 'weekly') { + const days = new Set( + series.weekdays.length + ? series.weekdays.map((day) => WEEKDAYS.indexOf(day)) + : [start.getUTCDay()], + ) + // Weeks run Sunday to Saturday and are numbered from the one + // starts_on falls in, so "every 2 weeks" means that week, the + // week after next, and so on. + const weekZero = start.getTime() - start.getUTCDay() * DAY_MS + + for (let t = start.getTime(); emitted < limit; t += DAY_MS) { + const date = new Date(t) + if (!within(date)) return + const week = Math.floor((t - weekZero) / (7 * DAY_MS)) + if (week % interval === 0 && days.has(date.getUTCDay())) { + yield isoDate(date) + emitted += 1 + } + } + return + } + + const year = start.getUTCFullYear() + const month = start.getUTCMonth() + const day = start.getUTCDate() + const weekday = start.getUTCDay() + const nth = weekOfMonth(start) + + for (let step = 0; emitted < limit; step += 1) { + const offset = month + step * interval + const y = year + Math.floor(offset / 12) + const m = offset % 12 + const date = + series.frequency === 'monthly_weekday' + ? nthWeekday(y, m, weekday, nth) + : new Date(Date.UTC(y, m, Math.min(day, daysInMonth(y, m)))) + if (!within(date)) return + yield isoDate(date) + emitted += 1 + } +} + +/** The next meetings from today on, soonest first. */ +export function upcomingOccurrences( + series: EventSeries | null | undefined, + startsOn?: string | null, + endsOn?: string | null, + limit = SERIES_UPCOMING_SHOWN, +): string[] { + if (!series) return [] + const from = today() + const out: string[] = [] + for (const date of occurrences(series, startsOn, endsOn)) { + if (date < from) continue + out.push(date) + if (out.length >= limit) break + } + return out +} + +/* ── Words ───────────────────────────────────────────────────── */ + +const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last'] + +function ordinalDay(n: number): string { + const tens = n % 100 + if (tens >= 11 && tens <= 13) return `${n}th` + return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}` +} + +function joinWords(words: string[]): string { + if (words.length <= 1) return words[0] ?? '' + return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}` +} + +function clock(value?: string | null): string | null { + const match = value?.match(/^(\d{2}):(\d{2})$/) + if (!match) return null + const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2]))) + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + }) +} + +/** "7:00 PM – 8:30 PM", "7:00 PM", or null. */ +export function seriesTimes(series: EventSeries | null | undefined): string | null { + if (!series) return null + const from = clock(series.start_time) + const to = clock(series.end_time) + if (from && to) return `${from} – ${to}` + return from ?? to +} + +/** + * "Every Tuesday and Thursday, 7:00 PM – 8:30 PM", + * "Every 2 weeks on Monday", "Monthly on the 2nd Tuesday", + * "Every 3 months on the 13th". Null for a one-off event, or a + * series with no start date to anchor it. + */ +export function seriesLabel( + series: EventSeries | null | undefined, + startsOn?: string | null, +): string | null { + const start = parseDate(startsOn) + if (!series || !start) return null + + const interval = Math.max(1, series.interval || 1) + let pattern: string + + if (series.frequency === 'weekly') { + const days = series.weekdays.length + ? series.weekdays.map((day) => WEEKDAY_NAMES[WEEKDAYS.indexOf(day)]) + : [WEEKDAY_NAMES[start.getUTCDay()]] + pattern = + interval === 1 + ? `Every ${joinWords(days)}` + : `Every ${interval === 2 ? 'other week' : `${interval} weeks`} on ${joinWords(days)}` + } else { + const on = + series.frequency === 'monthly_weekday' + ? `the ${ORDINALS[weekOfMonth(start)]} ${WEEKDAY_NAMES[start.getUTCDay()]}` + : `the ${ordinalDay(start.getUTCDate())}` + pattern = + interval === 1 + ? `Monthly on ${on}` + : `Every ${interval === 2 ? 'other month' : `${interval} months`} on ${on}` + } + + const times = seriesTimes(series) + return times ? `${pattern}, ${times}` : pattern +} + +/** '2026-10-13' → 'Tue, Oct 13, 2026'. */ +export function occurrenceLabel(date: string): string { + const parsed = parseDate(date) + if (!parsed) return date + return parsed.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} diff --git a/src/lib/useContent.ts b/src/lib/useContent.ts index 1753f6a..025dc94 100644 --- a/src/lib/useContent.ts +++ b/src/lib/useContent.ts @@ -14,6 +14,7 @@ import { detailPath } from './hrefs.ts' import { useRecord, type Resource } from './useRecord.ts' import type { EventType } from './eventTypes.ts' +import type { EventSeries } from './eventSeries.ts' /* ── Shared shapes ───────────────────────────────────────────── */ @@ -89,6 +90,8 @@ export type EventRecord = { ends_on?: string | null date_label?: string | null status: 'upcoming' | 'past' | 'cancelled' + /** The repeating schedule, or null for a one-off. */ + series: EventSeries | null location_label?: string | null locality?: string | null state_code?: string | null diff --git a/src/pages/EventDetail.tsx b/src/pages/EventDetail.tsx index 87a2ace..f1fe27d 100644 --- a/src/pages/EventDetail.tsx +++ b/src/pages/EventDetail.tsx @@ -31,6 +31,7 @@ import { import { awardHref, personHref, refHref } from '../lib/hrefs.ts' import { personPhoto } from '../lib/media.ts' import { eventTypeLabel } from '../lib/eventTypes.ts' +import { occurrenceLabel, seriesLabel, seriesTimes, upcomingOccurrences } from '../lib/eventSeries.ts' const TEAL = '#138ba0' const BODY = '#4a6b72' @@ -127,6 +128,46 @@ export default function EventDetail() { }, ] + // A cancelled series has no next meeting, whatever its dates say. + const upcoming = + event.status === 'cancelled' + ? [] + : upcomingOccurrences(event.series, event.starts_on, event.ends_on) + + if (upcoming.length > 0) { + const times = seriesTimes(event.series) + + sections.push({ + id: 'dates', + title: 'Upcoming dates', + blurb: seriesLabel(event.series, event.starts_on) ?? undefined, + accent, + background: '#eef9fb', + content: ( +
+
    + {upcoming.map((date) => ( +
  • +

    + {occurrenceLabel(date)} +

    + {times && ( +

    + {times} +

    + )} +
  • + ))} +
+
+ ), + }) + } + if (groups.length > 0) { sections.push({ id: 'people', @@ -197,7 +238,12 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) { [event.locality, event.state_code].filter(Boolean).join(', ') || (event.is_online ? 'Online' : null) - const when = event.date_label || dateRange(event.starts_on, event.ends_on) + const schedule = seriesLabel(event.series, event.starts_on) + // A series with no label of its own is described by its schedule + // rather than by a start-to-end range that reads like one long + // gathering. + const when = + event.date_label || (schedule ? null : dateRange(event.starts_on, event.ends_on)) return (
@@ -227,6 +273,7 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) { )} {when && {when}} + {schedule && {schedule}} {where && {where}} {event.is_online && where !== 'Online' && ( Online too diff --git a/src/pages/sections/EventList-Cards.tsx b/src/pages/sections/EventList-Cards.tsx index 927900b..bacd614 100644 --- a/src/pages/sections/EventList-Cards.tsx +++ b/src/pages/sections/EventList-Cards.tsx @@ -8,6 +8,7 @@ import { } from "../../data/eventData.js"; import { eventHref } from "../../lib/hrefs.ts"; import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts"; +import { seriesLabel } from "../../lib/eventSeries.ts"; import type { EventListItem } from "../../lib/useContent.ts"; import type { SectionToggleProps } from "../../lib/sections.tsx"; @@ -195,6 +196,7 @@ export function Card({ const color = ev.color || defaultColor; const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO; const eventLogo = ev.event_logo; + const schedule = seriesLabel(ev.series, ev.starts_on); const links = ev.links ?? []; const igHandle = ev.instagram || null; const igUrl = igHandle @@ -255,6 +257,7 @@ export function Card({

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} + {schedule &&

{schedule}

} {ev.location_label && (

{ev.location_label}

)} @@ -282,6 +285,7 @@ export function Card({

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} + {schedule &&

{schedule}

} {ev.location_label && (

{ev.location_label}

)} From 6ec2fb240a40b7c314b60f00bf3a4efc0e2e4e4c Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 05:05:45 -0500 Subject: [PATCH 2/5] 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 3/5] 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