Schema cleanup, publishable awards, event scopes, and a consolidated baseline #10

Merged
ngu-git-admin merged 4 commits from cleanup into main 2026-09-26 23:44:12 +01:00
19 changed files with 339 additions and 423 deletions
Showing only changes of commit 25e592bfea - Show all commits

View file

@ -71,7 +71,7 @@ Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `ad
## Migrations
- Sequential files: `001_`, `002_`, ...
- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block.
- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved.
- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. A rebuild whose table is named by views or triggers wraps the rename in `PRAGMA legacy_alter_table = ON ... OFF` (see 023).
## Integrations
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.

View file

@ -299,28 +299,27 @@ const events = {
columns: [
"id",
"title",
"section_id",
"scope_id",
"event_type",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
// No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"],
filters: ["scope_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
order: "starts_on IS NULL, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("scope_id", { required: true }),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// What kind of gathering, as against scope_id's whose gathering
// it is. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
@ -344,7 +343,6 @@ const events = {
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
// A repeating schedule. Columns rather than a side table: the
@ -410,12 +408,11 @@ const people = {
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
order: "sort_name, display_name",
},
columns: [
@ -433,7 +430,6 @@ const people = {
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
@ -505,18 +501,15 @@ const people = {
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
@ -575,8 +568,8 @@ const awards = {
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
columns: ["id", "org_id", "name", "description", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
@ -586,6 +579,7 @@ const awards = {
text("name", { required: true }),
text("description"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
};
@ -787,7 +781,7 @@ export const OPTION_QUERIES = {
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
event_scopes: "SELECT id, name AS label FROM event_scopes ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",

View file

@ -82,6 +82,16 @@ export function tx(db, fn) {
Migrations only ever go forward. To undo something, write a
new migration.
Foreign keys are off while migrations run, the recipe from
the SQLite docs for changing a table's shape. PRAGMA
foreign_keys is a no-op inside a transaction, so it has to be
set here, around the per-file transactions, rather than in
the file. With it on, rebuilding a table something references
(drop the old one, rename the new one into place) either
cascades into the children or fails the commit. Instead each
file ends with a foreign_key_check, and any orphan it leaves
rolls that file back.
───────────────────────────────────────────────────────────── */
export function migrate(db, { log = console.log } = {}) {
@ -92,25 +102,41 @@ export function migrate(db, { log = console.log } = {}) {
.sort();
let applied = 0;
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
db.exec("PRAGMA foreign_keys = OFF");
for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10);
try {
for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10);
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
}
if (version <= current) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
tx(db, () => {
db.exec(sql);
const orphans = db.prepare("PRAGMA foreign_key_check").all();
if (orphans.length > 0) {
const where = orphans
.slice(0, 5)
.map((o) => `${o.table} row ${o.rowid} → ${o.parent}`)
.join(", ");
throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`);
}
// Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`);
});
log(`migrated → ${file}`);
applied += 1;
}
if (version <= current) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
tx(db, () => {
db.exec(sql);
// Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`);
});
log(`migrated → ${file}`);
applied += 1;
} finally {
if (enforced) db.exec("PRAGMA foreign_keys = ON");
}
const final = db.prepare("PRAGMA user_version").get().user_version;

View file

@ -0,0 +1,23 @@
-- ═══════════════════════════════════════════════════════════════
-- 020 DROP WHAT NOTHING READS
--
-- people_lists and people_list_members were planned for
-- hand-picked rosters and never got a route, a descriptor or a
-- component. v_chapters and v_person_affiliations have no query
-- against them: the routes read organizations and affiliations
-- directly.
--
-- Views first, then the child table before its parent. No trigger
-- references either table, and nothing has a foreign key into
-- them, so no rebuild and no PRAGMA foreign_keys dance.
--
-- PRAGMA user_version; -- reads 19 before this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_chapters;
DROP VIEW IF EXISTS v_person_affiliations;
DROP TABLE IF EXISTS people_list_members;
DROP TABLE IF EXISTS people_lists;
PRAGMA user_version = 20; -- ← set to this migration's number

View file

@ -0,0 +1,32 @@
-- ═══════════════════════════════════════════════════════════════
-- 021 event_sections → event_scopes
--
-- 015 established that the table is a scope list — whose gathering
-- an event is — and kept the old name to avoid a rename across the
-- codebase. This is that rename: the table, and events.section_id
-- to scope_id. The API field and the useEvents filter follow in
-- the same change.
--
-- Both renames run with legacy_alter_table off (the default), so
-- SQLite carries them into the REFERENCES clause on events, the
-- index on section_id, and v_events, which selects e.*.
--
-- It also inserts the three scopes 015 only relabelled. The
-- retired seed script was what created national, regional and
-- partner, so a database built from migrations alone had no row
-- for the Retreats page's three bands to point at. INSERT OR
-- IGNORE with 015's names and order: a no-op where they exist.
--
-- PRAGMA user_version; -- reads 20 before this file
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE event_sections RENAME TO event_scopes;
ALTER TABLE events RENAME COLUMN section_id TO scope_id;
INSERT OR IGNORE INTO event_scopes (id, name, sort_order) VALUES
('national', 'National', 10),
('regional', 'Regional', 20),
('partner', 'Partner', 50);
PRAGMA user_version = 21; -- ← set to this migration's number

View file

@ -0,0 +1,36 @@
-- ═══════════════════════════════════════════════════════════════
-- 022 DROP events.sort_order AND people.sort_order
--
-- events.sort_order dates from the seed, when event dates were
-- free text ("March/April 2026") and a typed number was the only
-- way to order a list. Events have real dates now, and a new one
-- landed at 0 — ahead of everything — until someone renumbered.
-- The routes order by date instead.
--
-- people.sort_order was never read by the public site: people
-- sort by sort_name everywhere. It only reordered the admin list.
--
-- Every other sort_order stays. On child collections it is the
-- row's position, written by the admin engine from drag order;
-- on organizations, teams, awards, timeline_entries and
-- event_scopes it is an ordering the site actually uses.
--
-- SQLite refuses DROP COLUMN while an index names the column, so
-- the three indexes that do go first and come back without it.
-- No view names either column (v_events selects e.*).
--
-- PRAGMA user_version; -- reads 21 before this file
-- ═══════════════════════════════════════════════════════════════
DROP INDEX IF EXISTS events_section_idx;
DROP INDEX IF EXISTS events_type_idx;
DROP INDEX IF EXISTS people_sort_idx;
ALTER TABLE events DROP COLUMN sort_order;
ALTER TABLE people DROP COLUMN sort_order;
CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on);
CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on);
CREATE INDEX people_sort_idx ON people (is_published, sort_name);
PRAGMA user_version = 22; -- ← set to this migration's number

View file

@ -0,0 +1,87 @@
-- ═══════════════════════════════════════════════════════════════
-- 023 teams GETS created_at AND updated_at
--
-- Every other top-level entity has updated_at, which the admin
-- engine compares on save so two people editing the same row
-- can't silently overwrite each other. teams was left out because
-- ADD COLUMN can't take DEFAULT (datetime('now')) on a STRICT
-- table, so this is the rebuild — the recipe from the SQLite docs.
--
-- The runner turns foreign keys off around every migration and
-- runs foreign_key_check before committing (see migrate() in
-- db.js). So DROP TABLE doesn't touch affiliations, whose
-- (team_id, org_id) reference keeps pointing at the name `teams`,
-- which the rename puts back underneath it.
--
-- legacy_alter_table is on for the rename: v_org_leadership,
-- v_timeline and the timeline_entries ref triggers name `teams`.
-- Between the DROP and the RENAME that table doesn't exist, and
-- the modern rename re-parses every view and trigger and fails on
-- them. Legacy mode leaves them alone; they resolve to the new
-- table by name. Turned back off before the end, since the
-- connection outlives this file.
--
-- DROP TABLE takes the two AFTER DELETE triggers with it (and does
-- not fire them), so they're recreated below exactly as 002 and
-- 007 wrote them, along with teams_touch. Triggers go last: the
-- runner may drop statements that follow a BEGIN...END body.
--
-- Verify after:
--
-- PRAGMA user_version; -- 23
-- PRAGMA foreign_key_check; -- no rows
-- SELECT name FROM sqlite_master WHERE tbl_name = 'teams';
-- ═══════════════════════════════════════════════════════════════
PRAGMA legacy_alter_table = ON;
CREATE TABLE teams_new (
id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name TEXT NOT NULL,
tagline TEXT,
color TEXT,
logo TEXT,
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (id, org_id)
) STRICT;
-- Columns listed explicitly rather than SELECT *, so this breaks
-- loudly if the old shape isn't what this file assumes. Existing
-- rows get the migration time for both timestamps.
INSERT INTO teams_new
(id, org_id, name, tagline, color, logo, is_published, sort_order)
SELECT
id, org_id, name, tagline, color, logo, is_published, sort_order
FROM teams;
DROP TABLE teams;
ALTER TABLE teams_new RENAME TO teams;
CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
PRAGMA legacy_alter_table = OFF;
CREATE TRIGGER teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
END;
CREATE TRIGGER timeline_teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
END;
CREATE TRIGGER teams_touch
AFTER UPDATE ON teams
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE teams SET updated_at = datetime('now') WHERE id = new.id;
END;

View file

@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api
GET /events list + the section ids
GET /events list + the event scopes
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page
@ -20,8 +20,8 @@
from the dates when it isn't set. Components read one field and
don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which
band of the Retreats page an event belongs to, the type is what
`event_type` is orthogonal to `scope_id`: the scope is whose
gathering it is (national, regional, partner…), the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
@ -120,7 +120,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
return {
id: row.id,
section_id: row.section_id,
scope_id: row.scope_id,
event_type: row.event_type,
title: row.title,
@ -399,9 +399,7 @@ function attachTeams(db, orgs) {
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
/* The awards this organization gives. awards has no is_published
column, so every row is public the moment it exists — see the
note in the route below. */
/* The awards this organization gives, drafts left out. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
@ -414,7 +412,7 @@ function attachAwards(db, orgs) {
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
WHERE a.org_id IN (${marks(ids.length)})
WHERE a.org_id IN (${marks(ids.length)}) AND a.is_published = 1
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
@ -437,22 +435,33 @@ function attachAwards(db, orgs) {
}
/* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id.
Flat, with the event scopes alongside. Retreats.tsx owns the
band titles and colours and filters this list by scope_id.
By date, the way the cards split them: upcoming soonest first,
then past most recent first, undated at the end of each. There
is no hand-typed order; a tie falls to the title.
───────────────────────────────────────────────────────────── */
const EVENT_ORDER = `
effective_status = 'past',
starts_on IS NULL,
CASE WHEN effective_status = 'past' THEN NULL ELSE starts_on END,
starts_on DESC,
title`;
content.get("/events", (c) => {
const db = c.get("db");
const sections = db
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
const scopes = db
.prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`)
.all();
const rows = db
.prepare(
`SELECT * FROM v_events
WHERE is_published = 1
ORDER BY section_id, sort_order`,
ORDER BY ${EVENT_ORDER}`,
)
.all();
@ -470,7 +479,7 @@ content.get("/events", (c) => {
),
);
return json(c, { sections, events });
return json(c, { scopes, events });
});
@ -507,7 +516,7 @@ content.get("/events/:id", (c) => {
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
JOIN awards a ON a.id = pa.award_id
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
@ -622,7 +631,7 @@ content.get("/organizations/:id", (c) => {
FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
ORDER BY e.sort_order`,
ORDER BY ${EVENT_ORDER}`,
)
.all(id);
@ -694,12 +703,8 @@ content.get("/teams/:id", (c) => {
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the
moment somebody creates it, and there is no way to draft one.
That was fine while awards only appeared as a line on a
person's record; it is thinner ground now that each has a URL.
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
worth doing before this ships.
An unpublished award is a draft: left out of the list, and a
404 at its own URL, the same as any other unpublished row.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
@ -718,7 +723,7 @@ content.get("/awards", (c) => {
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""}
WHERE a.is_published = 1 ${org ? "AND a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
@ -741,7 +746,7 @@ content.get("/awards/:id", (c) => {
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`,
WHERE a.id = ? AND a.is_published = 1`,
)
.get(id);

View file

@ -151,7 +151,7 @@ home.get("/front-page", (c) => {
.prepare(
`SELECT * FROM v_events
WHERE ${notOver}
ORDER BY starts_on, sort_order
ORDER BY starts_on, title
LIMIT 1`,
)
.get();

View file

@ -225,7 +225,7 @@ people.get("/people/:id", (c) => {
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`,
ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`,
)
.all(id, id);

View file

@ -1,292 +0,0 @@
import { useState } from "react";
import nguLogo from "@/NGU_Logo.svg";
import fallLogo from "@/Fall Logo.svg";
import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg";
{/* SVGs */}
const DoveSVG = ({ className = "" }: { className?: string }) => (
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
<defs id="defs1" />
<g id="layer1" transform="translate(-70.490069,-117.83965)">
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
</g>
</g>
</svg>
);
const InstagramIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
);
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
);
{/* Link Tables */}
const Social_Links = [
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", icon: <InstagramIcon />},
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", icon: <FacebookIcon />},
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", icon: <DiscordIcon />},
]
const Nav_Links = [
{ label: "About", href: "#about"},
{ label: "Events", href: "#events"},
{ label: "Connect", href: "#connect"},
]
const Footer_Links = [
{ label: "Privacy Policy", href: "#"},
{ label: "Terms of Service", href: "#"},
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
]
{/* Functions */}
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
return (
<>
{text.split("").map((char, i) => (
<span
key={i}
className="float-anim"
style={{ animationDelay: `${-i * step}s` }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</>
);
}
export default function App() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState("main");
return (
<div className="min-h-screen overflow-x-hidden">
{/* ── NAV ─────────────────────────────────────────────── */}
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 backdrop-blur-md" style={{ background: "rgba(0, 69, 82,0.92)", borderBottom: "1px solid rgba(45,200,224,0.15)" }}>
<div className="flex items-center">
<a href="/" aria-label="Next Generation of Unity home" className="inline-block">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</a>
</div>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-8">
{Nav_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white/90 hover:text-[#aac992] text-sm font-600 transition-colors duration-200">
{link.label}
</a>
))}
<a href="https://ngu.churchcenter.com/giving" className="px-5 py-2 rounded-full text-sm font-700 text-white transition-all duration-200 hover:scale-105 font-bold leading-relaxed" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}>
Give
</a>
</div>
{/* Mobile menu btn */}
<button className="md:hidden text-white p-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all"/>
<div className="w-6 h-0.5 bg-white mb-1.5"/>
<div className="w-6 h-0.5 bg-white"/>
</button>
</nav>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center" style={{ background: "rgba(7,61,74,0.97)" }}>
<button className="absolute top-5 right-6 text-white text-3xl font-300" onClick={() => setMobileMenuOpen(false)}>×</button>
{Nav_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white text-2xl font-700 py-3 hover:text-[#10d48a] transition-colors" onClick={() => setMobileMenuOpen(false)}>
{link.label}
</a>
))}
<a href="https://ngu.churchcenter.com/giving" className="mt-6 px-8 py-3 rounded-full text-white text-lg font-700 font-bold" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)" }} onClick={() => setMobileMenuOpen(false)}>
Give
</a>
</div>
)}
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
<section id="about" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
{/* Floating doves */}
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
<div className="relative z-10 max-w-4xl mx-auto px-6">
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
Next Generation<br />
<span className="grad-hero-text">
<WaveText text="of Unity" step={0.1} />
</span>
</h1>
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
</p>
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
We are the future of the Unity Movement.
</p>
<div className="flex flex-wrap gap-4 justify-center">
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
Follow Us on Instagram
</a>
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
Attend a Retreat
</a>
</div>
</div>
</section>
{/* ── ABOUT/INFO ─────────────────────────────────────── */}
<section className="py-24 px-6" style={{ background: "#f0fcfd" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
].map((card) => (
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
<div className="text-4xl mb-4">{card.icon}</div>
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ── EVENTS ─────────────────────────────────────────────── */}
<section id="events" className="py-24 px-6" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
Upcoming Events
</h2>
</div>
{/* Featured event card */}
<div className="rounded-3xl text-black overflow-hidden shadow-2xl max-w-3xl mx-auto mb-10" style={{ border: "1px solid #b89421", background: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))" }}>
<div className="p-10">
<div className="grid grid-cols-2 mb-4">
<div className="">
<img src={nguLogo_WhiteBG} alt="Next Generation of Unity" className="h-15 w-auto mb-6" />
<h3 className="text-4xl md:text-4xl font-900">Fall Retreat 2026</h3>
<p className="text-2xl font-300 font-bold">"Consciousness Creates"</p>
<p className="text-2xl">November 12-15th, 2026</p>
<p className="text-2xl">Unity Village, MO</p>
</div>
<div className="flex justify-end">
<img src={fallLogo} alt="Next Generation of Unity" className="h-60" />
</div>
</div>
<p className="mb-2 leading-relaxed">Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus your highest self.</p>
<p className="mb-8 leading-relaxed">Registration starting at $150, and $75 loding cost.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ label: "Register Now!", link:"https://ngu.churchcenter.com/registrations/events/3761999"},
{ label: "Workshop Signup", link:"https://ngu.churchcenter.com/people/forms/1285943"},
{ label: "Scholarship Application", link:"https://ngu.churchcenter.com/people/forms/1261992"},
].map(item => (
<a key={item.label} href={item.link} className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center" style={{ border: "1px solid #b89421" }}>
{item.label}
</a>
))}
</div>
</div>
</div>
<p className="text-center text-[#138ba0] font-600 text-sm">· More events coming soon, stay connected for announcements ·</p>
</div>
</section>
{/* ── CONNECT / SOCIALS ─────────────────────────────────── */}
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
Ready to Connect?
</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{[
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
].map(item => (
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
<div>
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
</div>
{/* Arrow */}
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
</svg>
</a>
))}
</div>
<div className="mt-10 text-center">
<a href="https://ngu.churchcenter.com/calendar?view=gallery" className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}>
📅 View NGU Calendar
</a>
</div>
</div>
</section>
{/* ── FOOTER - Using #042f3a for BG ─────────────────────────────────────── */}
<footer className="py-16 px-6" style={{ background: "linear-gradient(135deg, #042f3a 0%, #073d4a 100%)" }}>
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
<div className="flex items-center gap-4">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</div>
<div className="flex gap-4">
{Social_Links.map((link) => (
<a key={link.label} href={link.href} className="w-10 h-10 rounded-full flex items-center justify-center text-white/70 hover:text-white transition-colors" style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}>
{link.icon}
</a>
))}
</div>
</div>
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
<div className="flex gap-6">
{Footer_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white/40 hover:text-[#10d48a] text-sm transition-colors">{link.label}</a>
))}
</div>
</div>
</div>
</footer>
</div>
);
}

View file

@ -1,25 +1,25 @@
/* ═══════════════════════════════════════════════════════════════
EVENT DATA
One request, filtered per section. The Retreats page has three
One request, filtered per band. The Retreats page has three
bands of events, and all three call this hook — the cache in
api.ts keys on the path, so they share a single fetch and each
narrows the result to what it shows.
useEvents({ section: "national" }) one band
useEvents({ scope: "national" }) one band
useEvents({ host: "northwest" }) one host's events
useEvents({ status: "upcoming" }) a home page strip
useEvents({ type: "workshop" }) one kind, wherever it is
useEvents({ type: ["class", "workshop"] })
useEvents() everything
`section` and `type` are different questions and stack rather
than overlap: the section is which band of the page an event
belongs to, the type is what kind of gathering it is. A regional
class matches both { section: "regional" } and { type: "class" }.
`scope` and `type` are different questions and stack rather
than overlap: the scope is whose gathering an event is, the type
is what kind of gathering it is. A regional class matches both
{ scope: "regional" } and { type: "class" }.
The event_sections rows (the scope list) come back alongside, for
anything that offers a scope filter.
The event_scopes rows come back alongside, for anything that
offers a scope filter.
No fallback. An empty list on a failed request would read as
"nothing scheduled" when the truth is "the server is down", so
@ -35,15 +35,15 @@ import { useMemo } from "react";
import { useResource } from "../lib/useResource.ts";
/* One shared empty list, so a memo keyed on `sections` doesn't
/* One shared empty list, so a memo keyed on `scopes` doesn't
restart on every render before the data arrives. */
const NO_SECTIONS: any[] = [];
const NO_SCOPES: any[] = [];
export function useEvents({ section, host, status, type }: any = {}) {
export function useEvents({ scope, host, status, type }: any = {}) {
const { data, error, loading } = useResource("/events");
const all = data?.events;
const sections = data?.sections ?? NO_SECTIONS;
const scopes = data?.scopes ?? NO_SCOPES;
/* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency
@ -52,7 +52,7 @@ export function useEvents({ section, host, status, type }: any = {}) {
const events = useMemo(() => {
let list = all ?? [];
if (section) list = list.filter(e => e.section_id === section);
if (scope) list = list.filter(e => e.scope_id === scope);
// `host` is an organization or a person slug, and an event can
// have several of either — co-hosting puts one event on both
// hosts' lists, which is the point.
@ -63,9 +63,9 @@ export function useEvents({ section, host, status, type }: any = {}) {
list = list.filter(e => wanted.has(e.event_type));
}
return list;
}, [all, section, host, status, typeKey]);
}, [all, scope, host, status, typeKey]);
return { events, sections, loading, error };
return { events, scopes, loading, error };
}
/* Past and upcoming, split. `status` arrives already resolved — the

View file

@ -27,8 +27,12 @@ const PLACE_FIELDS = [
{ path: "is_online", label: "Online", widget: "checkbox" },
];
const PUBLISHED_FIELD = { path: "is_published", label: "Published", widget: "checkbox" };
/* For entities whose lists the site orders by hand. Events sort by
date and people by sort_name, so they take PUBLISHED_FIELD alone. */
const PUBLISH_FIELDS = [
{ path: "is_published", label: "Published", widget: "checkbox" },
PUBLISHED_FIELD,
{ path: "sort_order", label: "Sort order", widget: "number" },
];
@ -392,14 +396,14 @@ const events = {
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Scope" },
{ key: "scope_id", label: "Scope" },
{ key: "event_type", label: "Type" },
{ key: "date_label", label: "Dates" },
{ key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "section_id", label: "Scope", optionsFrom: "event_sections" },
{ key: "scope_id", label: "Scope", optionsFrom: "event_scopes" },
{
key: "event_type",
label: "Type",
@ -414,15 +418,11 @@ const events = {
{
legend: "Identity",
fields: [
// The column is still section_id — the rows in event_sections
// are what changed, not the schema. Only the label moved,
// because "scope" is what the field has always meant and
// "section" described where it happened to be rendered.
{
path: "section_id",
path: "scope_id",
label: "Scope",
widget: "select",
optionsFrom: "event_sections",
optionsFrom: "event_scopes",
required: true,
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
},
@ -477,7 +477,7 @@ const events = {
},
timelineGroup("event"),
timelineDetailGroup("event"),
{ legend: "Publishing", fields: PUBLISH_FIELDS },
{ legend: "Publishing", fields: [PUBLISHED_FIELD] },
],
children: [
@ -592,7 +592,7 @@ const people = {
{
legend: "Publishing",
note: "Unpublished people are invisible everywhere, including as chapter leads.",
fields: PUBLISH_FIELDS,
fields: [PUBLISHED_FIELD],
},
],
@ -757,10 +757,11 @@ const awards = {
{ key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Awarded by" },
{ key: "description", label: "Description" },
{ key: "sort_order", label: "Order" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
@ -782,9 +783,13 @@ const awards = {
{ path: "name", label: "Name", required: true },
{ path: "description", label: "Description", widget: "textarea", full: true },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
{ path: "sort_order", label: "Sort order", widget: "number" },
],
},
{
legend: "Publishing",
note: "An unpublished award is a draft: off the site, and a 404 at its own page.",
fields: PUBLISH_FIELDS,
},
],
};

View file

@ -5,9 +5,9 @@
display order — the filter chips read it straight off this array,
so moving a line moves a chip.
What this is not: event_sections. A section owns presentation —
What this is not: event_scopes. A scope owns presentation —
Retreats.tsx keys its title, accent and background on the id, so
an unrecognised section_id makes an event vanish with no error,
an unrecognised scope_id makes an event vanish with no error,
which is why that one is a real table with a real foreign key. A
type carries no presentation of its own and an unknown value
renders as its own name, so a CHECK is enough.

View file

@ -79,9 +79,9 @@ export type EventAward = {
export type EventRecord = {
id: string
/** Which band of the Retreats page this belongs to. */
section_id: string
/** What kind of gathering it is. Orthogonal to section_id. */
/** Whose gathering it is: an event_scopes id. */
scope_id: string
/** What kind of gathering it is. Orthogonal to scope_id. */
event_type: EventType
title: string
theme?: string | null
@ -117,10 +117,10 @@ export type EventRecord = {
* blocks, people and awards. */
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
/** An event_sections row, as GET /events sends it beside the list. */
export type EventSection = { id: string; name: string; sort_order: number }
/** An event_scopes row, as GET /events sends it beside the list. */
export type EventScope = { id: string; name: string; sort_order: number }
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
export type EventsResponse = { scopes: EventScope[]; events: EventListItem[] }
export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event')

View file

@ -11,8 +11,8 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
Two filters per band now, and they answer different questions:
section whose gathering it is — the scope. event_sections
holds six of these; this page draws three.
scope whose gathering it is. event_scopes holds six of
these; this page draws three.
type what kind of gathering it is. Pinned to "retreat"
everywhere on this page, which is what the page is
for and what lets "Partner" read as a heading rather
@ -45,7 +45,7 @@ const SECTIONS = [
accent: "#138ba0",
background: "#eef9fb",
Component: EventListCards,
props: { section: "national", ...RETREATS },
props: { scope: "national", ...RETREATS },
views: { ...CARD_VIEWS, default: "carousel" },
},
{
@ -55,7 +55,7 @@ const SECTIONS = [
accent: "#aac992",
background: "#ffffff",
Component: EventListCards,
props: { section: "regional", ...RETREATS },
props: { scope: "regional", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" },
},
{
@ -65,13 +65,13 @@ const SECTIONS = [
accent: "#7a5ea8",
background: "#eef9fb",
Component: EventListCards,
props: { section: "partner", ...RETREATS },
props: { scope: "partner", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" },
},
/* The other three scopes, ready to uncomment. Each needs an accent
and a background of its own — those are presentation and live
here, not in event_sections.
here, not in event_scopes.
{
id: "local",
@ -80,7 +80,7 @@ const SECTIONS = [
accent: "#d08a3c",
background: "#ffffff",
Component: EventListCards,
props: { section: "local", ...RETREATS_ONLY },
props: { scope: "local", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},
{
@ -90,7 +90,7 @@ const SECTIONS = [
accent: "#3c7fd0",
background: "#eef9fb",
Component: EventListCards,
props: { section: "international", ...RETREATS_ONLY },
props: { scope: "international", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},
{
@ -100,7 +100,7 @@ const SECTIONS = [
accent: "#7a8a8e",
background: "#ffffff",
Component: EventListCards,
props: { section: "other", ...RETREATS_ONLY },
props: { scope: "other", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
},

View file

@ -7,7 +7,7 @@
<EventCalendar /> everything
<EventCalendar host="northwest" /> one host's calendar
<EventCalendar section="national" /> one scope
<EventCalendar scope="national" /> one scope
<EventCalendar type={["class", "workshop"]} controls={["search"]} />
Two layers of filtering, and they answer different questions:
@ -63,7 +63,7 @@ const BODY = '#4a6b72'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
type EventCalendarProps = {
section?: string
scope?: string
host?: string
status?: EventListItem['status']
type?: EventType | EventType[]
@ -223,7 +223,7 @@ function nextDateAfter(events: EventListItem[], after: string): string | null {
/* ── Component ───────────────────────────────────────────────── */
export default function EventCalendar({
section,
scope,
host,
status,
type,
@ -231,32 +231,32 @@ export default function EventCalendar({
controls = ALL_CONTROLS,
defaultView = 'month',
}: EventCalendarProps) {
const { events: pinned, sections, loading, error } = useEvents({ section, host, status, type })
const { events: pinned, scopes, loading, error } = useEvents({ scope, host, status, type })
const today = iso(new Date())
const [month, setMonth] = useState(monthKey(today))
const [view, setView] = useState<CalendarView>(defaultView)
const [selected, setSelected] = useState<string | null>(null)
const [scope, setScope] = useState('')
const [pickedScope, setPickedScope] = useState('')
const [kind, setKind] = useState('')
const [onlineOnly, setOnlineOnly] = useState(false)
const [query, setQuery] = useState('')
const show = (control: CalendarControl) => controls.includes(control)
const showScope = show('scope') && !section
const showScope = show('scope') && !scope
const showType = show('type') && !type
const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned])
const scopes = useMemo(() => {
const present = new Set(pinned.map((e) => e.section_id))
return sections.filter((s) => present.has(s.id))
}, [pinned, sections])
const scopeOptions = useMemo(() => {
const present = new Set(pinned.map((e) => e.scope_id))
return scopes.filter((s) => present.has(s.id))
}, [pinned, scopes])
const events = useMemo(() => {
const needle = query.trim().toLowerCase()
return pinned.filter((e) => {
if (scope && e.section_id !== scope) return false
if (pickedScope && e.scope_id !== pickedScope) return false
if (kind && e.event_type !== kind) return false
if (onlineOnly && !e.is_online) return false
if (needle) {
@ -274,7 +274,7 @@ export default function EventCalendar({
}
return true
})
}, [pinned, scope, kind, onlineOnly, query])
}, [pinned, pickedScope, kind, onlineOnly, query])
const weeks = useMemo(() => gridFor(month), [month])
const gridFrom = weeks[0][0]
@ -288,7 +288,7 @@ export default function EventCalendar({
)
const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo)
const undated = events.filter((e) => !e.starts_on).length
const filtering = Boolean(scope || kind || onlineOnly || query)
const filtering = Boolean(pickedScope || kind || onlineOnly || query)
const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
@ -299,7 +299,7 @@ export default function EventCalendar({
}
const clearFilters = () => {
setScope('')
setPickedScope('')
setKind('')
setOnlineOnly(false)
setQuery('')
@ -361,13 +361,13 @@ export default function EventCalendar({
{/* ── Filters ── */}
{(showScope || showType || show('online') || show('search')) && (
<div className="mt-5 flex flex-wrap items-center gap-3">
{showScope && scopes.length > 1 && (
{showScope && scopeOptions.length > 1 && (
<FilterSelect
label="Scope"
value={scope}
onChange={setScope}
value={pickedScope}
onChange={setPickedScope}
all="All scopes"
options={scopes.map((s) => [s.id, s.name])}
options={scopeOptions.map((s) => [s.id, s.name])}
/>
)}
{showType && types.length > 1 && (

View file

@ -10,16 +10,16 @@ import { seriesLabel } from "../../lib/eventSeries.ts";
A band of event cards, as a peek carousel or a grid. Self
contained: give it a filter and it fetches, so the same section
appears three times on Retreats with a different `section` each
appears three times on Retreats with a different `scope` each
time, and could appear on a region's page with `host` instead.
<EventListCards section="national" view="carousel" />
<EventListCards scope="national" view="carousel" />
<EventListCards host="northwest" view="grid" />
<EventListCards type={["class", "workshop"]} view="grid" />
`view` and `accent` come from the page's section manifest.
`type` pre-filters the band the way `section` and `host` do. Left
`type` pre-filters the band the way `scope` and `host` do. Left
off, the band takes every kind it finds and grows a row of chips
to narrow by — but only once it holds more than one, so a band of
nothing but retreats shows no control at all.
@ -446,7 +446,7 @@ export function EventCardsToggle({ view, setView, accent }) {
than all falling through to "coming soon".
═══════════════════════════════════════════════════════════════ */
export default function EventListCards({
section,
scope,
host,
status,
type,
@ -456,7 +456,7 @@ export default function EventListCards({
empty = "· Events coming soon, stay connected for announcements ·",
}: any) {
const { events: fetched, loading, error } = useEvents({
section,
scope,
host,
status,
type,

View file

@ -34,7 +34,7 @@ export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
</Link>
</div>
<EventListCards section="national" type="retreat" view="carousel" accent={TEAL} />
<EventListCards scope="national" type="retreat" view="carousel" accent={TEAL} />
</section>
)
}