diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e04f6b2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,78 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -# NGU-Web - -Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. - -## Stack -- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`) -- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) -- Package manager: pnpm only (never npm or yarn) - -## Commands -Frontend (repo root): -- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite -- `pnpm format`: oxfmt -- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script) -- There is no test suite. - -Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`: -- `DB_PATH=./dev.db pnpm dev`: run with `node --watch` -- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server -- `DB_PATH=./dev.db node src/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy. -- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created - -In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port. - -## Deployment (production) -- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` -- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` -- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service. -- Never run commands against the production server or database unless explicitly asked. - -## Git workflow -- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. -- Open pull requests with `tea`: `tea pr create --base main --head --title "..." --description "..."` -- Never commit directly to main. Create a branch per change, push it, open a PR. -- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked. -- `server/dev.db` and other `*.db` files are local only and never committed. - -## How to work in this repo -- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions. -- Ask questions up front before implementing non-trivial features. -- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked. -- Fix root causes. No redirect shims or workarounds. -- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components. -- Name components for what they do, not what they currently filter. - -## Project layout -- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise. -- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`. -- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. -- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). -- `api.js` is the shared caching client used by frontend data hooks. -- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `` component hides itself on load error. - -## Rules and gotchas -- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs. -- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. -- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. -- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. - -## Admin CRUD engine -Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code. -- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere. -- `reindex: false` prevents cross-entity sort order collisions. -- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. -- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree. -- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. - -## 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. - -## Integrations -- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 5a78d25..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -Test line added by Claude Code. diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index 732d154..d0fe17d 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -34,7 +34,6 @@ 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. */ @@ -99,13 +98,6 @@ 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; @@ -249,12 +241,6 @@ 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 @@ -372,12 +358,6 @@ 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 e457bb1..f1ce448 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -13,8 +13,6 @@ 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 @@ -42,7 +40,6 @@ 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", @@ -284,10 +281,6 @@ 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", @@ -346,18 +339,6 @@ 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")], @@ -652,133 +633,7 @@ const 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", "calendar", "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, -}; +export const ENTITIES = { organizations, events, people, teams, awards, timeline }; /* ── Options for the form's select inputs ────────────────────── */ diff --git a/server/src/index.js b/server/src/index.js index 6eab6ca..172e523 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -17,7 +17,6 @@ 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"; @@ -57,7 +56,6 @@ 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/016_event-series.sql b/server/src/migrations/016_event-series.sql deleted file mode 100644 index b766eb1..0000000 --- a/server/src/migrations/016_event-series.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql deleted file mode 100644 index 7be2891..0000000 --- a/server/src/migrations/017_front_page.sql +++ /dev/null @@ -1,189 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index ce48564..0000000 --- a/server/src/migrations/018_front_page_touch.sql +++ /dev/null @@ -1,16 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql deleted file mode 100644 index a6987b5..0000000 --- a/server/src/migrations/019_front_page_calendar.sql +++ /dev/null @@ -1,61 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- FRONT PAGE: calendar band --- --- Adds 'calendar' to the sections the front page can draw. The key --- is a CHECK, and SQLite can't alter a CHECK in place, so the table --- is rebuilt: new table, copy, drop, rename. --- --- No PRAGMA foreign_keys dance. front_page_sections only points out --- (at front_page); nothing points in, so dropping the old table --- cascades into nothing, and the copy keeps every page_id valid. --- --- The new band is inserted straight after the retreats carousel, --- where "what's on" reads naturally, by shifting everything below it --- down one. If retreats was removed on this box, it goes last. --- --- Adding another section later is the same three steps: this CHECK, --- the enum in both descriptor halves, and SECTIONS in Home.tsx. --- --- No BEGIN...END in this file. --- ═══════════════════════════════════════════════════════════════ - -CREATE TABLE front_page_sections_new ( - 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', 'calendar', 'stats', - 'timeline', 'connect')), - title TEXT, - blurb TEXT, - is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), - UNIQUE (page_id, section) -) STRICT; - -INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden) -SELECT id, page_id, sort_order, section, title, blurb, is_hidden - FROM front_page_sections; - -DROP TABLE front_page_sections; - -ALTER TABLE front_page_sections_new RENAME TO front_page_sections; - -UPDATE front_page_sections - SET sort_order = sort_order + 1 - WHERE page_id = 'home' - AND sort_order > COALESCE( - (SELECT sort_order FROM front_page_sections - WHERE page_id = 'home' AND section = 'retreats'), - (SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home')); - -INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) -SELECT 'home', - COALESCE( - (SELECT sort_order + 1 FROM front_page_sections - WHERE page_id = 'home' AND section = 'retreats'), - (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections - WHERE page_id = 'home')), - 'calendar', - 'What''s on', - 'Every gathering, class and meeting in one place.' - WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home'); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index 2138905..87aab06 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -47,14 +47,7 @@ import { Hono } from "hono"; -import { - asBool, - loadBlocks, - loadLinks, - paragraphs, - shapeSeries, - splitLinks, -} from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const content = new Hono(); @@ -131,7 +124,6 @@ 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/server/src/routes/home.js b/server/src/routes/home.js deleted file mode 100644 index bbf14c4..0000000 --- a/server/src/routes/home.js +++ /dev/null @@ -1,186 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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/routes/people.js b/server/src/routes/people.js index dbc31d9..e89ebca 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,7 +3,6 @@ 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 @@ -21,7 +20,7 @@ import { Hono } from "hono"; -import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; +import { asBool } from "../shape.js"; const people = new Hono(); @@ -139,161 +138,4 @@ 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/server/src/shape.js b/server/src/shape.js index 246d014..797db14 100644 --- a/server/src/shape.js +++ b/server/src/shape.js @@ -144,26 +144,4 @@ 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/App.tsx b/src/App.tsx index ff85789..9b7efd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,6 @@ 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"; @@ -55,7 +54,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index 056fd49..b2f9af0 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -4,15 +4,7 @@ import { Link } from "react-router-dom"; ARROW LINK ═══════════════════════════════════════════════════════════════ */ -type ArrowLinkProps = { - to: string; - label: string; - color: string; - /** Tailwind size classes for the circle. */ - size?: string; -}; - -export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) { +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { return ( element, so it nests inside another SVG as - well as in HTML: pass x, y, width and height to place it in a - parent viewBox. - ═══════════════════════════════════════════════════════════════ */ - -import type { SVGProps } from 'react' - -const PATH = - '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' - -export default function DoveMark(props: SVGProps) { - return ( - - ) -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c590eac..a4ea3a2 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -39,13 +39,7 @@ const Social_Links = [ // Only the label differs down here. const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy"); -/* An internal route, or an off-site address opened in a new tab. */ -type TouchLink = { label: string } & ( - | { external?: false; to: string } - | { external: true; href: string } -); - -const Get_In_Touch: TouchLink[] = [ +const Get_In_Touch = [ { label: "Feedback", to: "/feedback"}, { label: "Contact Us", to: "/leadership#contact" }, ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5474c73..2073f0a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -117,7 +117,7 @@ export default function Layout() { const targets = sections .map((s) => document.querySelector(s.hash)) - .filter((el): el is Element => el !== null); + .filter(Boolean); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 496f926..63ce434 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -29,27 +29,9 @@ /> ═══════════════════════════════════════════════════════════════ */ -import type { ReactNode } from "react"; - const TEAL = "#138ba0"; -export type ShellSection = { - id: string; - title: string; - blurb?: string; - accent: string; - background: string; - actions?: ReactNode; - content: ReactNode; -}; - -type PageShellProps = { - title: ReactNode; - intro?: ReactNode; - sections: ShellSection[]; -}; - -export function Section({ section }: { section: ShellSection }) { +export function Section({ section }) { const { id, title, @@ -88,7 +70,7 @@ export function Section({ section }: { section: ShellSection }) { ); } -export default function PageShell({ title, intro, sections }: PageShellProps) { +export default function PageShell({ title, intro, sections }) { return ( <> {/* Page header */} diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index ecff0d2..2f70d5d 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,16 +123,10 @@ text-align: inherit; } -.pl__tile--button, -.pl__tile--link { +.pl__tile--button { cursor: pointer; } -.pl__tile--link { - color: inherit; - text-decoration: none; -} - .pl__frame { position: relative; display: flex; @@ -156,20 +150,16 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .pl__frame, -.pl__tile--link:hover .pl__frame, -.pl__tile--link:focus-visible .pl__frame { +.pl__tile--button: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--link:focus-visible { +.pl__tile--button:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame, -.pl__tile--link:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -321,20 +311,6 @@ 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 62dcf4f..61fd716 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -8,10 +8,7 @@ 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"; /** @@ -46,13 +43,6 @@ 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 { @@ -174,7 +164,7 @@ export default function PeopleTiles({ Promise.all( specs.map((spec) => - get(`/teams/${spec.id}/people`, { ttl }).then((data) => ({ + get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({ spec, data, })), @@ -208,8 +198,8 @@ export default function PeopleTiles({ let live = true; setFailed(false); - get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) - .then((data) => { + get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data: { people: Person[] }) => { if (!live) return; const byId: Record = {}; for (const person of data.people) byId[String(person.id)] = person; @@ -314,10 +304,11 @@ function resolveAll( } const { peopleslug, ...overrides } = entry; - const defined: Partial = Object.fromEntries( - Object.entries(overrides).filter(([, value]) => value !== undefined), - ); - resolved.push({ ...base, ...defined }); + const merged: Person = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) (merged as Record)[key] = value; + } + resolved.push(merged); } return resolved; @@ -561,14 +552,7 @@ function Tile({ ); if (!expandable) { - const href = profileHref(person); - return href ? ( - - {content} - - ) : ( -
{content}
- ); + return
{content}
; } return ( @@ -627,7 +611,6 @@ 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 → - - )}
); } @@ -717,12 +694,6 @@ 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/components/admin/fields.tsx b/src/components/admin/fields.tsx index d3a761b..95296f8 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -32,16 +32,7 @@ able to read and copy. ═══════════════════════════════════════════════════════════════ */ -import { useRef, useState, type ChangeEvent, type ReactNode } from "react"; - -import type { FieldErrors } from "../../lib/api.js"; -import type { - AdminFieldSpec, - AdminOption, - AdminOptions, - AdminRow, - CollectionSpec, -} from "../../lib/adminSchema.js"; +import { useRef, useState } from "react"; const input = "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + @@ -65,56 +56,37 @@ const inputLocked = /* ── Dotted paths ────────────────────────────────────────────── */ -export function getPath(object: unknown, path: string | null | undefined): unknown { +export function getPath(object, path) { // An entity with no slug has no heading path either, and a missing // path should read as "no value" rather than throwing on .split. if (!path) return undefined; - return path - .split(".") - .reduce((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object); + return path.split(".").reduce((value, key) => value?.[key], object); } -export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { +export function setPath(object, path, value) { const [head, ...rest] = path.split("."); if (rest.length === 0) return { ...object, [head]: value }; - const inner = (object?.[head] ?? {}) as AdminRow; - return { ...object, [head]: setPath(inner, rest.join("."), value) }; + return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; } /* ── Field ───────────────────────────────────────────────────── */ -/* [value, label, the option row it came from]. Manifest options - have no row, which is what filterBy's `!raw` lets through. */ -type Choice = [id: string, label: string, raw?: AdminOption]; - -type FieldProps = { - field: AdminFieldSpec; - /* Whatever the row holds at field.path; shown as text. */ - value: unknown; - row?: AdminRow; - options?: AdminOptions | null; - error?: string; - onChange: (value: string | number) => void; -}; - -export function Field({ field, value, row, options, error, onChange }: FieldProps) { +export function Field({ field, value, row, options, error, onChange }) { const id = `f-${field.path.replace(/\./g, "-")}`; const widget = field.widget ?? "text"; const locked = Boolean(field.readOnly); - const text = value == null ? "" : String(value); - let list: Choice[] = []; + let list = null; let orphaned = false; if (widget === "select") { list = field.optionsFrom - ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o]) - : (field.options ?? []).map((o): Choice => - typeof o === "string" ? [o, o] : [o[0], o[1]], + ? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o]) + : (field.options ?? []).map((o) => + Array.isArray(o) ? [o[0], o[1]] : [o, o], ); - const { filterBy } = field; - if (filterBy && row) { - list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); + if (field.filterBy && row) { + list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row)); } // A stored value with no matching option renders as the blank @@ -130,9 +102,8 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp const common = { id, className: `${input} ${error ? inputError : ""}`, - value: text, - onChange: (e: ChangeEvent) => - onChange(e.target.value), + value: value ?? "", + onChange: (e) => onChange(e.target.value), }; return ( @@ -174,7 +145,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp }`} > - {orphaned && } + {orphaned && } {list.map(([id2, label]) => (