Compare commits
No commits in common. "main" and "v1.5" have entirely different histories.
75 changed files with 817 additions and 5832 deletions
78
CLAUDE.md
78
CLAUDE.md
|
|
@ -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 <branch> --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 `<Logo>` 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.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Test line added by Claude Code.
|
||||
|
|
@ -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": {
|
||||
// <input type="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),
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────── */
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/organizations/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/teams/:id" element={<TeamDetail />} />
|
||||
<Route path="/awards/:id" element={<AwardDetail />} />
|
||||
<Route path="/people/:id" element={<PersonDetail />} />
|
||||
<Route path="leadership" element={<Leadership />} />
|
||||
<Route path="resources" element={<Resources />} />
|
||||
<Route path="history" element={<History />} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
to={to}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
DOVE MARK
|
||||
|
||||
NGU's dove, lifted from the original home page. The path and its
|
||||
two transforms are the drawing as exported, untouched; only the
|
||||
wrapper changed — sized by the caller, coloured by currentColor,
|
||||
and hidden from screen readers since it's decoration wherever
|
||||
it appears.
|
||||
|
||||
Renders as an <svg> 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<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 72.867699 48.568241" aria-hidden="true" focusable="false" {...props}>
|
||||
<g transform="translate(-70.490069,-117.83965)">
|
||||
<g transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)">
|
||||
<path d={PATH} fill="currentColor" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<TeamResponse>(`/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<string, Person> = {};
|
||||
for (const person of data.people) byId[String(person.id)] = person;
|
||||
|
|
@ -314,10 +304,11 @@ function resolveAll(
|
|||
}
|
||||
|
||||
const { peopleslug, ...overrides } = entry;
|
||||
const defined: Partial<Person> = 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<string, unknown>)[key] = value;
|
||||
}
|
||||
resolved.push(merged);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
|
|
@ -561,14 +552,7 @@ function Tile({
|
|||
);
|
||||
|
||||
if (!expandable) {
|
||||
const href = profileHref(person);
|
||||
return href ? (
|
||||
<Link to={href} className="pl__tile pl__tile--link">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="pl__tile">{content}</div>
|
||||
);
|
||||
return <div className="pl__tile">{content}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -690,12 +673,6 @@ function DetailPanel({
|
|||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{profile && (
|
||||
<Link to={profile} className="pl__profile">
|
||||
View full profile →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown>((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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
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
|
|||
}`}
|
||||
>
|
||||
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||
{orphaned && <option value={text}>{text} — no longer exists</option>}
|
||||
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||
{list.map(([id2, label]) => (
|
||||
<option key={id2} value={id2}>
|
||||
{label}
|
||||
|
|
@ -185,7 +156,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
|||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#138ba0"}
|
||||
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
||||
|
|
@ -217,7 +188,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
|||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={text}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
||||
|
|
@ -225,9 +196,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
|||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={
|
||||
widget === "number" || widget === "date" || widget === "time" ? widget : "text"
|
||||
}
|
||||
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
||||
step={widget === "number" ? "any" : undefined}
|
||||
{...common}
|
||||
readOnly={locked}
|
||||
|
|
@ -253,36 +222,26 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
|||
);
|
||||
}
|
||||
|
||||
export function FieldGrid({ children }: { children: ReactNode }) {
|
||||
export function FieldGrid({ children }) {
|
||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
/* ── Repeater ────────────────────────────────────────────────── */
|
||||
|
||||
type RepeaterProps = {
|
||||
spec: CollectionSpec;
|
||||
rows: AdminRow[] | null | undefined;
|
||||
options?: AdminOptions | null;
|
||||
errors?: Partial<FieldErrors> | null;
|
||||
/** Where this collection's rows sit in the server's error keys. */
|
||||
errorPrefix: string;
|
||||
onChange: (rows: AdminRow[]) => void;
|
||||
};
|
||||
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||
const list = rows ?? [];
|
||||
|
||||
// Which row is in flight, and which one it's currently over.
|
||||
// Both are per-Repeater, which is what keeps a drag inside a
|
||||
// nested collection from being accepted by the outer one.
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
const [overIndex, setOverIndex] = useState(null);
|
||||
const rowRefs = useRef([]);
|
||||
|
||||
const update = (index: number, next: AdminRow) =>
|
||||
const update = (index, next) =>
|
||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||
|
||||
const move = (index: number, delta: number) => {
|
||||
const move = (index, delta) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= list.length) return;
|
||||
const next = [...list];
|
||||
|
|
@ -290,7 +249,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
|||
onChange(next);
|
||||
};
|
||||
|
||||
const relocate = (from: number | null, to: number | null) => {
|
||||
const relocate = (from, to) => {
|
||||
if (from === to || from == null || to == null) return;
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(from, 1);
|
||||
|
|
@ -423,7 +382,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
|||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||
<Repeater
|
||||
spec={nested}
|
||||
rows={row[nested.key] as AdminRow[] | undefined}
|
||||
rows={row[nested.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||
|
|
@ -439,15 +398,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
|||
);
|
||||
}
|
||||
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
|
||||
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
10
src/data/bannerConfig.d.ts
vendored
10
src/data/bannerConfig.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
|||
/* Types for bannerConfig.js. */
|
||||
|
||||
export type BannerPart = { text: string; href?: string; external?: boolean };
|
||||
|
||||
export declare const SITE_BANNER: {
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
content: BannerPart[];
|
||||
dismissible: boolean;
|
||||
};
|
||||
47
src/data/chapters.d.ts
vendored
47
src/data/chapters.d.ts
vendored
|
|
@ -1,47 +0,0 @@
|
|||
/* Types for chapters.js: GET /organizations rows with their
|
||||
kind-specific details lifted to the top level. */
|
||||
|
||||
import type { OrganizationListItem, RegionArea, RegionScope } from "../lib/useContent.ts";
|
||||
import type { AreaSlice, RegionAreaRow } from "./mapGrid.js";
|
||||
|
||||
export { initialsFor } from "./organizations.js";
|
||||
|
||||
export type Region = OrganizationListItem & {
|
||||
scope: RegionScope | null;
|
||||
map_note: string | null;
|
||||
areas: RegionArea[];
|
||||
};
|
||||
|
||||
export type Chapter = OrganizationListItem & {
|
||||
region_id: string | null;
|
||||
region_name: string | null;
|
||||
region_color: string | null;
|
||||
meets: string | null;
|
||||
started: string | null;
|
||||
/** The map tile this chapter lights up, or null if it has none. */
|
||||
area_code: string | null;
|
||||
};
|
||||
|
||||
export type Community = {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
|
||||
regions: Region[];
|
||||
regionAreas: RegionAreaRow[];
|
||||
regionById: Record<string, Region>;
|
||||
domestic: Region[];
|
||||
international: Region[];
|
||||
virtual: Region[];
|
||||
|
||||
chapters: Chapter[];
|
||||
chaptersIn: (regionId: string) => Chapter[];
|
||||
|
||||
slices: Record<string, AreaSlice[]>;
|
||||
chapterCounts: Record<string, number>;
|
||||
regionsForArea: (areaCode: string) => Region[];
|
||||
|
||||
areasLabelFor: (regionId: string) => string;
|
||||
subtextFor: (region: Pick<Region, "id" | "map_note">) => string;
|
||||
};
|
||||
|
||||
export declare function useCommunity(): Community;
|
||||
29
src/data/eventData.d.ts
vendored
29
src/data/eventData.d.ts
vendored
|
|
@ -1,29 +0,0 @@
|
|||
/* Types for eventData.js. Rows are GET /events as shapeEvent in
|
||||
server/src/routes/content.js sends them. */
|
||||
|
||||
import type { EventType } from "../lib/eventTypes.ts";
|
||||
import type { EventListItem, EventSection } from "../lib/useContent.ts";
|
||||
|
||||
export type EventFilter = {
|
||||
section?: string;
|
||||
host?: string;
|
||||
status?: EventListItem["status"];
|
||||
type?: EventType | EventType[];
|
||||
};
|
||||
|
||||
export declare function useEvents(filter?: EventFilter): {
|
||||
events: EventListItem[];
|
||||
/** event_sections, in scope order. Empty until loaded. */
|
||||
sections: EventSection[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export declare function splitByStatus<T extends { status: string }>(
|
||||
events?: T[],
|
||||
): { upcoming: T[]; past: T[] };
|
||||
|
||||
export declare function typesPresent<D extends { id: string }>(
|
||||
events?: Array<{ event_type: string }>,
|
||||
declared?: readonly D[],
|
||||
): D[];
|
||||
|
|
@ -18,13 +18,6 @@
|
|||
belongs to, the type is what kind of gathering it is. A regional
|
||||
class matches both { section: "regional" } and { type: "class" }.
|
||||
|
||||
The event_sections rows (the scope list) 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
|
||||
the error comes back and each caller says so.
|
||||
|
||||
Filtering here rather than in the query keeps the endpoint to
|
||||
one cached response. At a few dozen events that's the right
|
||||
trade; if the list ever runs to hundreds, move the filters into
|
||||
|
|
@ -35,15 +28,12 @@ import { useMemo } from "react";
|
|||
|
||||
import { useResource } from "../lib/useResource.js";
|
||||
|
||||
/* One shared empty list, so a memo keyed on `sections` doesn't
|
||||
restart on every render before the data arrives. */
|
||||
const NO_SECTIONS = [];
|
||||
const EMPTY = { events: [] };
|
||||
|
||||
export function useEvents({ section, host, status, type } = {}) {
|
||||
const { data, error, loading } = useResource("/events");
|
||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
||||
|
||||
const all = data?.events;
|
||||
const sections = data?.sections ?? NO_SECTIONS;
|
||||
|
||||
/* An array prop is a new identity on every render, which would
|
||||
restart the memo each time. Joining it gives the dependency
|
||||
|
|
@ -65,7 +55,7 @@ export function useEvents({ section, host, status, type } = {}) {
|
|||
return list;
|
||||
}, [all, section, host, status, typeKey]);
|
||||
|
||||
return { events, sections, loading, error };
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||
|
|
|
|||
7
src/data/feedbackTypes.d.ts
vendored
7
src/data/feedbackTypes.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
|||
/* Types for feedbackTypes.js. */
|
||||
|
||||
export type FeedbackType = { id: string; label: string; hint: string };
|
||||
|
||||
export declare const FEEDBACK_TYPES: FeedbackType[];
|
||||
|
||||
export declare function feedbackTypeLabel(id: string): string;
|
||||
51
src/data/mapGrid.d.ts
vendored
51
src/data/mapGrid.d.ts
vendored
|
|
@ -1,51 +0,0 @@
|
|||
/* Types for mapGrid.js. */
|
||||
|
||||
import type { RegionArea } from "../lib/useContent.ts";
|
||||
|
||||
export declare const GRID_COLS: number;
|
||||
export declare const GRID_ROWS: number;
|
||||
|
||||
export declare const AREA_NAMES: Record<string, string>;
|
||||
|
||||
/** One tile: a state, or a band such as CANADA with a span. */
|
||||
export type MapArea = {
|
||||
code: string;
|
||||
name: string;
|
||||
col: number;
|
||||
row: number;
|
||||
span: number;
|
||||
isState: boolean;
|
||||
};
|
||||
|
||||
export declare const AREAS: readonly MapArea[];
|
||||
export declare const AREA_BY_CODE: Record<string, MapArea>;
|
||||
|
||||
/** A region_areas row flattened with its region, as buildAreaSlices takes it. */
|
||||
export type RegionAreaRow = RegionArea & { region_id: string };
|
||||
|
||||
/** One region's share of a tile. */
|
||||
export type AreaSlice = {
|
||||
regionId: string;
|
||||
name: string;
|
||||
color: string | null | undefined;
|
||||
share: number;
|
||||
edge: RegionArea["edge"];
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
type Locatable = {
|
||||
is_online?: boolean;
|
||||
country?: string | null;
|
||||
state_code?: string | null;
|
||||
};
|
||||
|
||||
export declare function areaForChapter(chapter: Locatable | null | undefined): string | null;
|
||||
|
||||
export declare function buildAreaSlices(
|
||||
regionAreas?: RegionAreaRow[],
|
||||
regions?: Array<{ id: string; name: string; color?: string | null }>,
|
||||
): Record<string, AreaSlice[]>;
|
||||
|
||||
export declare function countChaptersByArea(chapters?: Locatable[]): Record<string, number>;
|
||||
|
||||
export declare function areasLabel(regionId: string, regionAreas?: RegionAreaRow[]): string;
|
||||
21
src/data/organizations.d.ts
vendored
21
src/data/organizations.d.ts
vendored
|
|
@ -1,21 +0,0 @@
|
|||
/* Types for organizations.js. Rows are GET /organizations as
|
||||
shapeOrganization in server/src/routes/content.js sends them. */
|
||||
|
||||
import type { OrgKind } from "../lib/hrefs.ts";
|
||||
import type { OrganizationListItem, RegionArea } from "../lib/useContent.ts";
|
||||
|
||||
export declare function useOrganizations(kind?: OrgKind): {
|
||||
organizations: OrganizationListItem[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export declare function orgPath(
|
||||
org: { id: string; kind?: string | null } | null | undefined,
|
||||
): string | null;
|
||||
|
||||
export declare function areasSentence(
|
||||
areas?: Array<Pick<RegionArea, "area_code" | "note">>,
|
||||
): string;
|
||||
|
||||
export declare function initialsFor(name?: string): string;
|
||||
123
src/lib/adminSchema.d.ts
vendored
123
src/lib/adminSchema.d.ts
vendored
|
|
@ -1,123 +0,0 @@
|
|||
/* Types for adminSchema.js: the client half of the descriptor-driven
|
||||
admin CRUD engine. Rows are whatever columns the server-side
|
||||
descriptor in server/src/admin-schema.js declares, so they stay a
|
||||
string-keyed record; the descriptors are what's fixed. */
|
||||
|
||||
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
|
||||
* ('region.scope') are side-table objects under their key, and
|
||||
* child collections are arrays of rows under theirs. */
|
||||
export type AdminRow = Record<string, unknown>;
|
||||
|
||||
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
|
||||
* along on the lists that filterBy narrows. */
|
||||
export type AdminOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
org_id?: string | null;
|
||||
};
|
||||
|
||||
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
|
||||
export type AdminOptions = Record<string, AdminOption[]>;
|
||||
|
||||
export type FieldWidget =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "number"
|
||||
| "date"
|
||||
| "time";
|
||||
|
||||
/** A bare value, or [value, label]. */
|
||||
export type SelectOption = string | readonly [string, string];
|
||||
|
||||
/** Show a group or collection only when another field has this value. */
|
||||
export type FieldCondition = { path: string; value: unknown };
|
||||
|
||||
export type AdminFieldSpec = {
|
||||
path: string;
|
||||
label: string;
|
||||
widget?: FieldWidget;
|
||||
required?: boolean;
|
||||
full?: boolean;
|
||||
help?: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
blankLabel?: string;
|
||||
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
|
||||
/* Set by EntityEdit on the id field rather than in a manifest. */
|
||||
readOnly?: boolean;
|
||||
prefix?: string;
|
||||
prefixPending?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type FieldGroupSpec = {
|
||||
legend: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
fields: AdminFieldSpec[];
|
||||
};
|
||||
|
||||
export type CollectionSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
addLabel?: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
|
||||
blank: AdminRow;
|
||||
fields: AdminFieldSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type ListColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
widget?: "bool";
|
||||
};
|
||||
|
||||
export type ListFilter = {
|
||||
key: string;
|
||||
label: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
};
|
||||
|
||||
export type EntitySpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
singular: string;
|
||||
idLabel: string;
|
||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
||||
idKind?: "auto";
|
||||
/** The one id a singleton entity has. The list opens it directly,
|
||||
* and the editor offers no slug, back link or delete. */
|
||||
singleton?: string;
|
||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
||||
slugFrom?: string | string[];
|
||||
titleFrom?: string;
|
||||
list: { columns: ListColumn[]; filters: ListFilter[] };
|
||||
groups: FieldGroupSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type AdminEntityKey =
|
||||
| "organizations"
|
||||
| "events"
|
||||
| "people"
|
||||
| "teams"
|
||||
| "awards"
|
||||
| "timeline"
|
||||
| "front_page";
|
||||
|
||||
/* Indexed by route param as often as by name, so any other string
|
||||
reads as possibly missing. */
|
||||
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
|
||||
readonly [key: string]: EntitySpec | undefined;
|
||||
};
|
||||
|
||||
export declare function slugify(value: unknown): string;
|
||||
|
|
@ -326,62 +326,6 @@ const organizations = {
|
|||
|
||||
/* ── Events ──────────────────────────────────────────────────── */
|
||||
|
||||
/* The panel the Series checkbox opens. Starts and Ends above stay
|
||||
the series' bounds — the first meeting and the last day it can
|
||||
meet — so nothing here repeats them. */
|
||||
const SERIES_WEEKDAYS = [
|
||||
["sun", "Sunday"],
|
||||
["mon", "Monday"],
|
||||
["tue", "Tuesday"],
|
||||
["wed", "Wednesday"],
|
||||
["thu", "Thursday"],
|
||||
["fri", "Friday"],
|
||||
["sat", "Saturday"],
|
||||
];
|
||||
|
||||
const seriesGroup = {
|
||||
legend: "Series",
|
||||
when: { path: "is_series", value: 1 },
|
||||
note:
|
||||
"Starts is the first meeting and anchors the schedule; Ends, if set, is the last " +
|
||||
"day it can meet. The weekdays only apply to a weekly series; with none ticked it " +
|
||||
"meets on the start date's day. Leave Date label blank and the site describes " +
|
||||
"the schedule itself.",
|
||||
fields: [
|
||||
{
|
||||
path: "series_frequency",
|
||||
label: "Repeats",
|
||||
widget: "select",
|
||||
options: [
|
||||
["weekly", "Weekly, on the days ticked below"],
|
||||
["monthly_date", "Monthly, on the start date's day (the 13th)"],
|
||||
["monthly_weekday", "Monthly, on the start date's weekday (2nd Tuesday)"],
|
||||
],
|
||||
blankLabel: "— weekly —",
|
||||
},
|
||||
{
|
||||
path: "series_interval",
|
||||
label: "Every",
|
||||
widget: "number",
|
||||
help: "1 for every week or month, 2 for every other, and so on",
|
||||
},
|
||||
{ path: "series_start_time", label: "Start time", widget: "time" },
|
||||
{ path: "series_end_time", label: "End time", widget: "time" },
|
||||
{
|
||||
path: "series_count",
|
||||
label: "Number of meetings",
|
||||
widget: "number",
|
||||
help: "Stops after this many. Blank to run until Ends, or indefinitely",
|
||||
},
|
||||
...SERIES_WEEKDAYS.map(([day, name]) => ({
|
||||
path: `series_${day}`,
|
||||
label: name,
|
||||
widget: "checkbox",
|
||||
help: `Meets on ${name}s`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
const events = {
|
||||
key: "events",
|
||||
label: "Events",
|
||||
|
|
@ -456,15 +400,8 @@ const events = {
|
|||
options: ["upcoming", "past", "cancelled"],
|
||||
blankLabel: "— derive from end date —",
|
||||
},
|
||||
{
|
||||
path: "is_series",
|
||||
label: "Series",
|
||||
widget: "checkbox",
|
||||
help: "Repeats on a schedule — a weekly class, a monthly meeting",
|
||||
},
|
||||
],
|
||||
},
|
||||
seriesGroup,
|
||||
{ legend: "Where", fields: PLACE_FIELDS },
|
||||
{
|
||||
legend: "Appearance",
|
||||
|
|
@ -941,202 +878,7 @@ const timeline = {
|
|||
],
|
||||
};
|
||||
|
||||
/* ── Front page ──────────────────────────────────────────────────
|
||||
|
||||
One record (see the singleton note in server/src/admin-schema.js).
|
||||
The groups are the hero; the collections below are the rest of
|
||||
the page. Photos and the livestream are editable whatever the
|
||||
mode, so either can be ready before the switch is flipped.
|
||||
|
||||
Section keys and stat sources are the CHECK lists in migrations
|
||||
017 and 019. The labels here are what the admin reads; the values are
|
||||
what the page and the API key on. */
|
||||
|
||||
const FRONT_PAGE_SECTIONS = [
|
||||
["countdown", "Countdown to the next event"],
|
||||
["retreats", "National Retreats carousel"],
|
||||
["calendar", "Event calendar"],
|
||||
["stats", "Numbers"],
|
||||
["timeline", "Featured timeline"],
|
||||
["connect", "Find your way in"],
|
||||
];
|
||||
|
||||
const STAT_SOURCES = [
|
||||
["manual", "Typed in — shows Value"],
|
||||
["years_since", "Years since — Value is the year"],
|
||||
["regions", "Count: published regions"],
|
||||
["chapters", "Count: published chapters"],
|
||||
["partners", "Count: published partners"],
|
||||
["events_held", "Count: past events"],
|
||||
["retreats_held", "Count: past retreats"],
|
||||
["people", "Count: published people"],
|
||||
["awards_given", "Count: public awards given"],
|
||||
];
|
||||
|
||||
const frontPage = {
|
||||
key: "front_page",
|
||||
label: "Front page",
|
||||
singular: "front page",
|
||||
idLabel: "Page",
|
||||
singleton: "home",
|
||||
|
||||
list: { columns: [], filters: [] },
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Hero",
|
||||
note: "The first thing a visitor sees. Blank buttons don't render.",
|
||||
fields: [
|
||||
{
|
||||
path: "hero_mode",
|
||||
label: "Mode",
|
||||
widget: "select",
|
||||
options: [
|
||||
["brand", "Brand — animated colour, no media"],
|
||||
["photos", "Photos — cycles through the photos below"],
|
||||
["livestream", "Livestream — embeds the stream with a LIVE badge"],
|
||||
],
|
||||
blankLabel: "— brand —",
|
||||
help: "Switch to Livestream when you go live, and back when you're done",
|
||||
},
|
||||
{ path: "eyebrow", label: "Eyebrow", help: "Small line above the headline" },
|
||||
{ path: "headline", label: "Headline", full: true },
|
||||
{ path: "subhead", label: "Subhead", widget: "textarea", full: true },
|
||||
{ path: "primary_label", label: "Main button label" },
|
||||
{ path: "primary_url", label: "Main button link", help: "/retreats, #connect or a full URL" },
|
||||
{ path: "secondary_label", label: "Second button label" },
|
||||
{ path: "secondary_url", label: "Second button link" },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Photos and livestream",
|
||||
fields: [
|
||||
{
|
||||
path: "slide_seconds",
|
||||
label: "Seconds per photo",
|
||||
widget: "number",
|
||||
help: "Photos mode. 3 to 60",
|
||||
},
|
||||
{
|
||||
path: "livestream_url",
|
||||
label: "Livestream link",
|
||||
full: true,
|
||||
help: "A YouTube, Facebook or Vimeo link, or any embed URL",
|
||||
},
|
||||
{ path: "livestream_title", label: "Livestream title", full: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Countdown",
|
||||
fields: [
|
||||
{
|
||||
path: "countdown_event_id",
|
||||
label: "Count down to",
|
||||
widget: "select",
|
||||
optionsFrom: "events",
|
||||
blankLabel: "— the next upcoming event —",
|
||||
help: "Leave blank almost always. A pinned event that's over falls back to the next one",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "sections",
|
||||
label: "Page sections",
|
||||
note: "Drag to reorder. Each section can appear once; leave one out to drop it.",
|
||||
addLabel: "Add section",
|
||||
title: (row) =>
|
||||
FRONT_PAGE_SECTIONS.find(([key]) => key === row.section)?.[1] ?? "New section",
|
||||
blank: { section: "", title: "", blurb: "" },
|
||||
fields: [
|
||||
{
|
||||
path: "section",
|
||||
label: "Section",
|
||||
widget: "select",
|
||||
options: FRONT_PAGE_SECTIONS,
|
||||
required: true,
|
||||
},
|
||||
{ path: "title", label: "Heading", help: "Blank for the section's own" },
|
||||
{ path: "blurb", label: "Blurb", full: true },
|
||||
{ path: "is_hidden", label: "Hidden", widget: "checkbox", help: "Hide for now" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "slides",
|
||||
label: "Hero photos",
|
||||
note: "Shown in Photos mode, in this order.",
|
||||
addLabel: "Add photo",
|
||||
title: (row) => row.caption || row.media || "New photo",
|
||||
blank: { media: "", alt: "", caption: "" },
|
||||
fields: [
|
||||
{ path: "media", label: "Photo", required: true, help: "Filename in public/front-page/, or a URL" },
|
||||
{ path: "alt", label: "Description", help: "For screen readers" },
|
||||
{ path: "caption", label: "Caption", full: true },
|
||||
{ path: "link_url", label: "Link", help: "Optional; makes the caption a link" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "stats",
|
||||
label: "Numbers",
|
||||
note: "Counts update themselves. A typed-in number with no value is left out.",
|
||||
addLabel: "Add number",
|
||||
title: (row) => row.label || "New number",
|
||||
blank: { label: "", source: "", value: "" },
|
||||
fields: [
|
||||
{ path: "label", label: "Label", required: true },
|
||||
{
|
||||
path: "source",
|
||||
label: "Where it comes from",
|
||||
widget: "select",
|
||||
options: STAT_SOURCES,
|
||||
blankLabel: "— typed in —",
|
||||
},
|
||||
{ path: "value", label: "Value", help: "Typed in: the number. Years since: the year" },
|
||||
{ path: "suffix", label: "Suffix", help: "+, k, % …" },
|
||||
{ path: "note", label: "Note", full: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "paths",
|
||||
label: "Find your way in",
|
||||
note: "Each path is a choice a visitor can pick; its actions appear when they do.",
|
||||
addLabel: "Add path",
|
||||
title: (row) => [row.icon, row.label].filter(Boolean).join(" ") || "New path",
|
||||
blank: { label: "", icon: "", blurb: "" },
|
||||
fields: [
|
||||
{ path: "label", label: "Label", required: true, help: "Attend, Serve…" },
|
||||
{ path: "icon", label: "Icon", help: "One emoji" },
|
||||
{ path: "blurb", label: "Blurb", full: true },
|
||||
],
|
||||
children: [
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
addLabel: "Add action",
|
||||
title: (row) => row.label || "New action",
|
||||
blank: { label: "", url: "" },
|
||||
fields: [
|
||||
{ path: "label", label: "Label", required: true },
|
||||
{ path: "url", label: "Link", required: true },
|
||||
{ path: "description", label: "Description", full: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ADMIN_ENTITIES = {
|
||||
organizations,
|
||||
events,
|
||||
people,
|
||||
teams,
|
||||
awards,
|
||||
timeline,
|
||||
front_page: frontPage,
|
||||
};
|
||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
|
||||
|
||||
export function slugify(value) {
|
||||
return String(value ?? "")
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@
|
|||
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
|
||||
export type AdminTitleValue = { setDetail: (value: string | null) => void };
|
||||
|
||||
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
|
||||
export const AdminTitleContext = createContext(null);
|
||||
|
||||
/* Publish the name of whatever this page is showing. Clears on
|
||||
unmount, so navigating away can't leave a stale record name in
|
||||
the tab. */
|
||||
export function useAdminDetail(name: string | null | undefined) {
|
||||
export function useAdminDetail(name) {
|
||||
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
26
src/lib/api.d.ts
vendored
26
src/lib/api.d.ts
vendored
|
|
@ -1,26 +0,0 @@
|
|||
/* Types for api.js. The response type is the caller's to name:
|
||||
every endpoint sends a different body, so get<T> defaults to
|
||||
unknown rather than pretending to know. */
|
||||
|
||||
/** Per-field messages, as admin-crud.js and feedback.js send them. */
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
export declare class ApiError extends Error {
|
||||
status?: number;
|
||||
fields?: FieldErrors;
|
||||
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
|
||||
}
|
||||
|
||||
export declare function get<T = unknown>(
|
||||
path: string,
|
||||
options?: { ttl?: number; fallback?: T },
|
||||
): Promise<T>;
|
||||
|
||||
export declare function invalidate(path?: string): void;
|
||||
|
||||
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
/* A 204 comes back as null. */
|
||||
export declare function del<T = null>(path: string): Promise<T>;
|
||||
|
|
@ -13,40 +13,21 @@
|
|||
staring at an empty table.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { get, post, ApiError } from "./api.js";
|
||||
import type { Role } from "./roles.ts";
|
||||
|
||||
/* What currentUser() in server/src/auth.js returns, and what
|
||||
/auth/me and /auth/login send under `user`. */
|
||||
export type AdminUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
};
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
type AuthResponse = { user: AdminUser };
|
||||
|
||||
type AuthValue = {
|
||||
user: AdminUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AdminUser>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
get<AuthResponse>("/auth/me", { ttl: 0 })
|
||||
get("/auth/me", { ttl: 0 })
|
||||
.then((data) => {
|
||||
if (!ignore) setUser(data.user);
|
||||
})
|
||||
|
|
@ -62,8 +43,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await post<AuthResponse>("/auth/login", { email, password });
|
||||
const login = useCallback(async (email, password) => {
|
||||
const data = await post("/auth/login", { email, password });
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
|
@ -92,7 +73,7 @@ export function useAuth() {
|
|||
|
||||
/* Signals a session that ended while the page was open — the
|
||||
admin pages call this when a request comes back 401. */
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
export function isUnauthorized(error) {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EMBEDS
|
||||
|
||||
Turns the link an admin pastes into the URL an <iframe> can
|
||||
load. People paste the page they're looking at — a YouTube watch
|
||||
or live link, a Facebook video, a Vimeo page — not the embed
|
||||
form, so those are recognised and rewritten. Anything else that
|
||||
is https is assumed to already be an embed URL and passed
|
||||
through; anything that isn't https is refused, so the hero never
|
||||
frames a plain-http or javascript: URL.
|
||||
|
||||
Streams start muted: browsers only autoplay muted video, and a
|
||||
hero that starts shouting is worse than one that asks.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
function youtubeId(url: URL): string | null {
|
||||
const host = url.hostname.replace(/^www\.|^m\./, '')
|
||||
if (host === 'youtu.be') return url.pathname.slice(1) || null
|
||||
if (host !== 'youtube.com' && host !== 'youtube-nocookie.com') return null
|
||||
|
||||
const v = url.searchParams.get('v')
|
||||
if (v) return v
|
||||
|
||||
// /live/ID, /embed/ID, /shorts/ID
|
||||
const match = url.pathname.match(/^\/(?:live|embed|shorts)\/([\w-]+)/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/** An iframe src for a pasted stream link, or null if it can't be framed. */
|
||||
export function livestreamEmbedUrl(raw?: string | null): string | null {
|
||||
if (!raw) return null
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw.trim())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'https:') return null
|
||||
|
||||
const yt = youtubeId(url)
|
||||
if (yt) {
|
||||
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(yt)}?autoplay=1&mute=1&playsinline=1`
|
||||
}
|
||||
|
||||
const host = url.hostname.replace(/^www\./, '')
|
||||
|
||||
if (host === 'vimeo.com') {
|
||||
const id = url.pathname.match(/^\/(?:event\/)?(\d+)/)?.[1]
|
||||
if (id) {
|
||||
return url.pathname.startsWith('/event/')
|
||||
? `https://vimeo.com/event/${id}/embed?autoplay=1&muted=1`
|
||||
: `https://player.vimeo.com/video/${id}?autoplay=1&muted=1`
|
||||
}
|
||||
}
|
||||
|
||||
if ((host === 'facebook.com' || host === 'fb.watch') && !url.pathname.startsWith('/plugins/')) {
|
||||
return `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url.href)}&autoplay=1&mute=1`
|
||||
}
|
||||
|
||||
return url.href
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT SERIES
|
||||
|
||||
An event with is_series set meets on a schedule rather than once.
|
||||
The API sends the schedule as-is (shapeSeries in content.js); this
|
||||
file is the one place that turns it into words and dates, so the
|
||||
cards and the detail page can't describe the same series two ways.
|
||||
|
||||
Occurrences are derived, never stored. starts_on is the first
|
||||
meeting and the anchor: it fixes which weeks an every-other-week
|
||||
series is "on", which day a monthly one keeps, and the weekday
|
||||
when none is ticked. ends_on, when set, is the last day it can
|
||||
meet; count, when set, stops it after that many meetings.
|
||||
|
||||
Dates are 'YYYY-MM-DD' and are handled as UTC midnights so that
|
||||
stepping a day never lands on a DST gap. They are calendar dates,
|
||||
not instants — nothing here converts timezones.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export type SeriesFrequency = 'weekly' | 'monthly_date' | 'monthly_weekday'
|
||||
|
||||
export type SeriesWeekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
|
||||
|
||||
export type EventSeries = {
|
||||
frequency: SeriesFrequency
|
||||
interval: number
|
||||
/** Ticked days, Sunday first. Empty means starts_on's weekday. */
|
||||
weekdays: SeriesWeekday[]
|
||||
/** 'HH:MM', 24-hour. */
|
||||
start_time?: string | null
|
||||
end_time?: string | null
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
/** How many upcoming meetings the event page lists. */
|
||||
export const SERIES_UPCOMING_SHOWN = 6
|
||||
|
||||
/* Past this many meetings the walk stops, whatever the schedule
|
||||
says. Twenty years of a daily-ish weekly series is well inside
|
||||
it; an open-ended series with a start date decades back is what
|
||||
it's for. */
|
||||
const MAX_OCCURRENCES = 5000
|
||||
|
||||
/* Index is Date#getUTCDay. */
|
||||
const WEEKDAYS: SeriesWeekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
||||
const WEEKDAY_NAMES = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
/* ── Dates ───────────────────────────────────────────────────── */
|
||||
|
||||
function parseDate(value?: string | null): Date | null {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||
const date = new Date(`${value}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const isoDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||
|
||||
/* The viewer's today, as a calendar date. */
|
||||
function today(): string {
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
const daysInMonth = (year: number, month: number) =>
|
||||
new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
|
||||
/* 1st–4th, or 5 for a date in the month's fifth week, which the
|
||||
schedule treats as "last" — every month has a last Tuesday, not
|
||||
every month has a fifth. */
|
||||
const weekOfMonth = (date: Date) => Math.ceil(date.getUTCDate() / 7)
|
||||
|
||||
function nthWeekday(year: number, month: number, weekday: number, nth: number): Date {
|
||||
if (nth >= 5) {
|
||||
const last = new Date(Date.UTC(year, month, daysInMonth(year, month)))
|
||||
const back = (last.getUTCDay() - weekday + 7) % 7
|
||||
return new Date(last.getTime() - back * DAY_MS)
|
||||
}
|
||||
const first = new Date(Date.UTC(year, month, 1))
|
||||
const ahead = (weekday - first.getUTCDay() + 7) % 7
|
||||
return new Date(Date.UTC(year, month, 1 + ahead + (nth - 1) * 7))
|
||||
}
|
||||
|
||||
/* ── Occurrences ─────────────────────────────────────────────── */
|
||||
|
||||
/* Every meeting date in schedule order, lazily, bounded by ends_on,
|
||||
count and MAX_OCCURRENCES. */
|
||||
function* occurrences(
|
||||
series: EventSeries,
|
||||
startsOn?: string | null,
|
||||
endsOn?: string | null,
|
||||
): Generator<string> {
|
||||
const start = parseDate(startsOn)
|
||||
if (!start) return
|
||||
|
||||
const end = parseDate(endsOn)
|
||||
const limit = Math.min(series.count ?? MAX_OCCURRENCES, MAX_OCCURRENCES)
|
||||
const interval = Math.max(1, series.interval || 1)
|
||||
let emitted = 0
|
||||
|
||||
const within = (date: Date) => !end || date.getTime() <= end.getTime()
|
||||
|
||||
if (series.frequency === 'weekly') {
|
||||
const days = new Set(
|
||||
series.weekdays.length
|
||||
? series.weekdays.map((day) => WEEKDAYS.indexOf(day))
|
||||
: [start.getUTCDay()],
|
||||
)
|
||||
// Weeks run Sunday to Saturday and are numbered from the one
|
||||
// starts_on falls in, so "every 2 weeks" means that week, the
|
||||
// week after next, and so on.
|
||||
const weekZero = start.getTime() - start.getUTCDay() * DAY_MS
|
||||
|
||||
for (let t = start.getTime(); emitted < limit; t += DAY_MS) {
|
||||
const date = new Date(t)
|
||||
if (!within(date)) return
|
||||
const week = Math.floor((t - weekZero) / (7 * DAY_MS))
|
||||
if (week % interval === 0 && days.has(date.getUTCDay())) {
|
||||
yield isoDate(date)
|
||||
emitted += 1
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const year = start.getUTCFullYear()
|
||||
const month = start.getUTCMonth()
|
||||
const day = start.getUTCDate()
|
||||
const weekday = start.getUTCDay()
|
||||
const nth = weekOfMonth(start)
|
||||
|
||||
for (let step = 0; emitted < limit; step += 1) {
|
||||
const offset = month + step * interval
|
||||
const y = year + Math.floor(offset / 12)
|
||||
const m = offset % 12
|
||||
const date =
|
||||
series.frequency === 'monthly_weekday'
|
||||
? nthWeekday(y, m, weekday, nth)
|
||||
: new Date(Date.UTC(y, m, Math.min(day, daysInMonth(y, m))))
|
||||
if (!within(date)) return
|
||||
yield isoDate(date)
|
||||
emitted += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** The next meetings from today on, soonest first. */
|
||||
export function upcomingOccurrences(
|
||||
series: EventSeries | null | undefined,
|
||||
startsOn?: string | null,
|
||||
endsOn?: string | null,
|
||||
limit = SERIES_UPCOMING_SHOWN,
|
||||
): string[] {
|
||||
if (!series) return []
|
||||
const from = today()
|
||||
const out: string[] = []
|
||||
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||
if (date < from) continue
|
||||
out.push(date)
|
||||
if (out.length >= limit) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every meeting between two dates, inclusive, in order. For a
|
||||
* calendar page: `from` and `to` are the visible range. */
|
||||
export function occurrencesBetween(
|
||||
series: EventSeries | null | undefined,
|
||||
startsOn: string | null | undefined,
|
||||
endsOn: string | null | undefined,
|
||||
from: string,
|
||||
to: string,
|
||||
): string[] {
|
||||
if (!series) return []
|
||||
const out: string[] = []
|
||||
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||
if (date > to) break
|
||||
if (date >= from) out.push(date)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The first meeting on or after `from`, or null if the series has
|
||||
* ended by then. */
|
||||
export function firstOccurrenceFrom(
|
||||
series: EventSeries | null | undefined,
|
||||
startsOn: string | null | undefined,
|
||||
endsOn: string | null | undefined,
|
||||
from: string,
|
||||
): string | null {
|
||||
if (!series) return null
|
||||
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||
if (date >= from) return date
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── Words ───────────────────────────────────────────────────── */
|
||||
|
||||
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
|
||||
|
||||
function ordinalDay(n: number): string {
|
||||
const tens = n % 100
|
||||
if (tens >= 11 && tens <= 13) return `${n}th`
|
||||
return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`
|
||||
}
|
||||
|
||||
function joinWords(words: string[]): string {
|
||||
if (words.length <= 1) return words[0] ?? ''
|
||||
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
|
||||
}
|
||||
|
||||
function clock(value?: string | null): string | null {
|
||||
const match = value?.match(/^(\d{2}):(\d{2})$/)
|
||||
if (!match) return null
|
||||
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])))
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
/** "7:00 PM – 8:30 PM", "7:00 PM", or null. */
|
||||
export function seriesTimes(series: EventSeries | null | undefined): string | null {
|
||||
if (!series) return null
|
||||
const from = clock(series.start_time)
|
||||
const to = clock(series.end_time)
|
||||
if (from && to) return `${from} – ${to}`
|
||||
return from ?? to
|
||||
}
|
||||
|
||||
/**
|
||||
* "Every Tuesday and Thursday, 7:00 PM – 8:30 PM",
|
||||
* "Every 2 weeks on Monday", "Monthly on the 2nd Tuesday",
|
||||
* "Every 3 months on the 13th". Null for a one-off event, or a
|
||||
* series with no start date to anchor it.
|
||||
*/
|
||||
export function seriesLabel(
|
||||
series: EventSeries | null | undefined,
|
||||
startsOn?: string | null,
|
||||
): string | null {
|
||||
const start = parseDate(startsOn)
|
||||
if (!series || !start) return null
|
||||
|
||||
const interval = Math.max(1, series.interval || 1)
|
||||
let pattern: string
|
||||
|
||||
if (series.frequency === 'weekly') {
|
||||
const days = series.weekdays.length
|
||||
? series.weekdays.map((day) => WEEKDAY_NAMES[WEEKDAYS.indexOf(day)])
|
||||
: [WEEKDAY_NAMES[start.getUTCDay()]]
|
||||
pattern =
|
||||
interval === 1
|
||||
? `Every ${joinWords(days)}`
|
||||
: `Every ${interval === 2 ? 'other week' : `${interval} weeks`} on ${joinWords(days)}`
|
||||
} else {
|
||||
const on =
|
||||
series.frequency === 'monthly_weekday'
|
||||
? `the ${ORDINALS[weekOfMonth(start)]} ${WEEKDAY_NAMES[start.getUTCDay()]}`
|
||||
: `the ${ordinalDay(start.getUTCDate())}`
|
||||
pattern =
|
||||
interval === 1
|
||||
? `Monthly on ${on}`
|
||||
: `Every ${interval === 2 ? 'other month' : `${interval} months`} on ${on}`
|
||||
}
|
||||
|
||||
const times = seriesTimes(series)
|
||||
return times ? `${pattern}, ${times}` : pattern
|
||||
}
|
||||
|
||||
/** '2026-10-13' → 'Tue, Oct 13, 2026'. */
|
||||
export function occurrenceLabel(date: string): string {
|
||||
const parsed = parseDate(date)
|
||||
if (!parsed) return date
|
||||
return parsed.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
|
@ -31,10 +31,6 @@ export const orgLogo = inDir('/org-logos') // ⚠ guess
|
|||
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
||||
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
||||
|
||||
/* front_page_slides.media — the home page hero's photos. New with
|
||||
the Front page editor, which names the directory in its help. */
|
||||
export const heroPhoto = inDir('/front-page')
|
||||
|
||||
/* content_blocks.media, which can be an image on any owner's page,
|
||||
so it can't share a per-entity directory. */
|
||||
export const blockMedia = inDir('/media') // ⚠ guess
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION MANIFEST
|
||||
|
|
@ -33,69 +33,25 @@ import { useState, type ComponentType, type ReactNode } from "react";
|
|||
want them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* What every section Component is handed on top of its own props. */
|
||||
export type SectionInjected = { accent: string; view?: string };
|
||||
|
||||
export type SectionToggleProps = {
|
||||
view: string;
|
||||
setView: (value: string) => void;
|
||||
accent: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type SectionViews = {
|
||||
options: string[];
|
||||
default?: string;
|
||||
Toggle: ComponentType<SectionToggleProps>;
|
||||
};
|
||||
|
||||
export type SectionHeading = {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb?: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
|
||||
Component: ComponentType<P & SectionInjected>;
|
||||
/* The Component decides P; props are checked against it. */
|
||||
props?: NoInfer<P>;
|
||||
views?: SectionViews;
|
||||
};
|
||||
|
||||
/* A manifest mixes components with different props, which no single
|
||||
array element type can check. This checks each entry against its
|
||||
own Component where it's written, then forgets P so the entries
|
||||
fit one array. */
|
||||
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
|
||||
return entry as unknown as SectionEntry;
|
||||
}
|
||||
|
||||
export type ManifestSection = SectionHeading & {
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
|
||||
export function useSectionManifest(manifest) {
|
||||
// One entry per section that has a toggle, seeded from its
|
||||
// declared default so the control is right before anything loads.
|
||||
const [views, setViews] = useState<Record<string, string | null>>(() =>
|
||||
const [views, setViews] = useState(() =>
|
||||
Object.fromEntries(
|
||||
manifest
|
||||
.filter(entry => entry.views)
|
||||
.map(entry => [
|
||||
entry.id,
|
||||
entry.views?.default ?? entry.views?.options[0] ?? null,
|
||||
entry.views.default ?? entry.views.options?.[0] ?? null,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
|
||||
return manifest.map(entry => {
|
||||
const { Component, props, views: spec, ...heading } = entry;
|
||||
const view = views[entry.id] ?? undefined;
|
||||
const view = views[entry.id];
|
||||
const Toggle = spec?.Toggle;
|
||||
|
||||
return {
|
||||
|
|
@ -103,7 +59,7 @@ export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[]
|
|||
|
||||
actions: Toggle ? (
|
||||
<Toggle
|
||||
view={view ?? ""}
|
||||
view={view}
|
||||
setView={value => setView(entry.id, value)}
|
||||
accent={entry.accent}
|
||||
options={spec.options}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@
|
|||
* and the public/ layout stay the frontend's business.
|
||||
*/
|
||||
|
||||
import type { OrgKind } from './hrefs.ts'
|
||||
|
||||
export type DatePrecision = 'year' | 'month' | 'day'
|
||||
|
||||
/** What an entry is about. Drives the marker and the body layout. */
|
||||
|
|
@ -43,8 +41,6 @@ export type TimelineRef = {
|
|||
kind: RefKind
|
||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||
id: string
|
||||
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
||||
orgKind?: OrgKind
|
||||
}
|
||||
|
||||
/** Filename plus the table it came from; the directory is derived
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
import { detailPath } from './hrefs.ts'
|
||||
import { useRecord, type Resource } from './useRecord.ts'
|
||||
import type { EventType } from './eventTypes.ts'
|
||||
import type { EventSeries } from './eventSeries.ts'
|
||||
|
||||
/* ── Shared shapes ───────────────────────────────────────────── */
|
||||
|
||||
|
|
@ -90,8 +89,6 @@ export type EventRecord = {
|
|||
ends_on?: string | null
|
||||
date_label?: string | null
|
||||
status: 'upcoming' | 'past' | 'cancelled'
|
||||
/** The repeating schedule, or null for a one-off. */
|
||||
series: EventSeries | null
|
||||
location_label?: string | null
|
||||
locality?: string | null
|
||||
state_code?: string | null
|
||||
|
|
@ -106,22 +103,12 @@ export type EventRecord = {
|
|||
hosts: EventHost[]
|
||||
description: string[]
|
||||
links: Link[]
|
||||
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
instagram?: Link | null
|
||||
blocks: ContentBlock[]
|
||||
people: EventPerson[]
|
||||
awards: EventAward[]
|
||||
}
|
||||
|
||||
/** One row of GET /events: shapeEvent without the detail-only
|
||||
* 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 }
|
||||
|
||||
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
|
||||
|
||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||
|
||||
|
|
@ -167,17 +154,6 @@ export type OrgEvent = {
|
|||
color?: string | null
|
||||
}
|
||||
|
||||
/** regions.scope's CHECK values. */
|
||||
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
||||
|
||||
/** A region_areas row as attachRegionDetails sends it. */
|
||||
export type RegionArea = {
|
||||
area_code: string
|
||||
share: number
|
||||
edge: 'top' | 'bottom' | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export type OrganizationRecord = {
|
||||
id: string
|
||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||
|
|
@ -197,16 +173,14 @@ export type OrganizationRecord = {
|
|||
blocks: ContentBlock[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
/* splitLinks in shape.js lifts these out as bare strings: the
|
||||
website's url, the email's label, the instagram handle. */
|
||||
website?: string | null
|
||||
email?: string | null
|
||||
instagram?: string | null
|
||||
website?: Link | null
|
||||
email?: Link | null
|
||||
instagram?: Link | null
|
||||
/** Shape depends on `kind`; empty object for national and partner. */
|
||||
details: {
|
||||
scope?: RegionScope | null
|
||||
scope?: string | null
|
||||
map_note?: string | null
|
||||
areas?: RegionArea[]
|
||||
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||
region_id?: string | null
|
||||
region_name?: string | null
|
||||
|
|
@ -220,14 +194,6 @@ export type OrganizationRecord = {
|
|||
events: OrgEvent[]
|
||||
}
|
||||
|
||||
/** One row of GET /organizations: the card surface and details,
|
||||
* without the sections only the org's own page loads. */
|
||||
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
||||
|
||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||
|
||||
|
|
@ -245,8 +211,7 @@ export type TeamRecord = {
|
|||
description: string[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
/** The instagram handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
instagram?: Link | null
|
||||
blocks: ContentBlock[]
|
||||
}
|
||||
|
||||
|
|
@ -276,63 +241,3 @@ export type AwardRecord = {
|
|||
|
||||
export const useAward = (id?: string): Resource<AwardRecord> =>
|
||||
useRecord<AwardRecord>(detailPath('/awards', id), 'award')
|
||||
|
||||
/* ── People ──────────────────────────────────────────────────── */
|
||||
|
||||
/** One public affiliation. ended_on null means current. */
|
||||
export type PersonRole = {
|
||||
title?: string | null
|
||||
role: 'lead' | 'board' | 'staff' | 'volunteer' | 'member'
|
||||
is_owner: boolean
|
||||
started_on?: string | null
|
||||
ended_on?: string | null
|
||||
org: OrgRef
|
||||
/** Null when there's no team, or the team is unpublished. */
|
||||
team: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
/** A published event this person was billed at or hosted, with
|
||||
* every capacity they appeared in. 'host' comes from event_hosts. */
|
||||
export type PersonEvent = {
|
||||
id: string
|
||||
title: string
|
||||
event_type: EventType
|
||||
date_label?: string | null
|
||||
starts_on?: string | null
|
||||
status: 'upcoming' | 'past' | 'cancelled'
|
||||
roles: { role: string; title?: string | null }[]
|
||||
}
|
||||
|
||||
export type PersonAward = {
|
||||
award: { id: string; name: string; logo?: string | null }
|
||||
awarded_on?: string | null
|
||||
citation?: string | null
|
||||
event: { id: string; title: string } | null
|
||||
}
|
||||
|
||||
export type PersonRecord = {
|
||||
id: string
|
||||
name: string
|
||||
pronouns?: string | null
|
||||
tagline?: string | null
|
||||
photo?: string | null
|
||||
location_label?: string | null
|
||||
public_email?: string | null
|
||||
/** The primary organization, when it's published. */
|
||||
org: OrgRef | null
|
||||
/** people.bio split on blank lines. */
|
||||
bio: string[]
|
||||
description: string[]
|
||||
blocks: ContentBlock[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
website?: string | null
|
||||
instagram?: string | null
|
||||
/** Current first, then most recently ended. */
|
||||
roles: PersonRole[]
|
||||
events: PersonEvent[]
|
||||
awards: PersonAward[]
|
||||
}
|
||||
|
||||
export const usePerson = (id?: string): Resource<PersonRecord> =>
|
||||
useRecord<PersonRecord>(detailPath('/people', id), 'person')
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FRONT PAGE DATA
|
||||
|
||||
GET /front-page, as routes/home.js shapes it: the hero, the
|
||||
visible sections in order, counted stats, paths with actions,
|
||||
and the countdown's event. Edited in the admin under Front page.
|
||||
|
||||
No fallback content. If the request fails the page says so — a
|
||||
plausible default home page would hide a broken server behind
|
||||
something that looks fine.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRecord, type Resource } from './useRecord.ts'
|
||||
import type { EventSeries } from './eventSeries.ts'
|
||||
|
||||
export type HeroMode = 'brand' | 'photos' | 'livestream'
|
||||
|
||||
/** The CHECK list on front_page_sections.section. */
|
||||
export type FrontPageSectionKey =
|
||||
| 'countdown'
|
||||
| 'retreats'
|
||||
| 'calendar'
|
||||
| 'stats'
|
||||
| 'timeline'
|
||||
| 'connect'
|
||||
|
||||
export type HeroButton = { label: string; url: string }
|
||||
|
||||
export type HeroSlide = {
|
||||
media: string
|
||||
alt?: string | null
|
||||
caption?: string | null
|
||||
link_url?: string | null
|
||||
}
|
||||
|
||||
export type Hero = {
|
||||
mode: HeroMode
|
||||
eyebrow?: string | null
|
||||
headline: string
|
||||
subhead?: string | null
|
||||
primary: HeroButton | null
|
||||
secondary: HeroButton | null
|
||||
slide_seconds: number
|
||||
slides: HeroSlide[]
|
||||
livestream: { url: string; title?: string | null } | null
|
||||
}
|
||||
|
||||
export type FrontPageSection = {
|
||||
section: FrontPageSectionKey
|
||||
/** Null → the section's own heading. */
|
||||
title?: string | null
|
||||
blurb?: string | null
|
||||
}
|
||||
|
||||
export type FrontPageStat = {
|
||||
label: string
|
||||
/** Already counted or typed; never null — the API drops those. */
|
||||
value: string
|
||||
suffix?: string | null
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export type PathAction = { label: string; description?: string | null; url: string }
|
||||
|
||||
export type FrontPagePath = {
|
||||
label: string
|
||||
icon?: string | null
|
||||
blurb?: string | null
|
||||
actions: PathAction[]
|
||||
}
|
||||
|
||||
export type CountdownEvent = {
|
||||
id: string
|
||||
title: string
|
||||
theme?: string | null
|
||||
starts_on?: string | null
|
||||
ends_on?: string | null
|
||||
date_label?: string | null
|
||||
location_label?: string | null
|
||||
is_online: boolean
|
||||
color?: string | null
|
||||
event_logo?: string | null
|
||||
series: EventSeries | null
|
||||
}
|
||||
|
||||
export type FrontPage = {
|
||||
hero: Hero
|
||||
sections: FrontPageSection[]
|
||||
stats: FrontPageStat[]
|
||||
paths: FrontPagePath[]
|
||||
countdown: CountdownEvent | null
|
||||
}
|
||||
|
||||
export const useFrontPage = (): Resource<FrontPage> =>
|
||||
useRecord<FrontPage>('/front-page', 'front_page')
|
||||
|
|
@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
|||
setError(null)
|
||||
setNotFound(false)
|
||||
try {
|
||||
const body = await get<Record<string, unknown> | null>(path as string)
|
||||
const body = await get(path as string)
|
||||
if (!live) return
|
||||
// A 200 with the key absent is a server-side shaping bug,
|
||||
// not an empty record. Say so rather than rendering a page
|
||||
|
|
|
|||
20
src/navConfig.d.ts
vendored
20
src/navConfig.d.ts
vendored
|
|
@ -1,20 +0,0 @@
|
|||
/* Types for navConfig.js. */
|
||||
|
||||
export type PageLink = { label: string; path: string };
|
||||
|
||||
export type PageSectionLink = { label: string; hash: string };
|
||||
|
||||
export type NavAction = {
|
||||
label: string;
|
||||
to: string;
|
||||
/** Picks the styling, not the destination. */
|
||||
variant: "ghost" | "fancy";
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export declare const PAGE_LINKS: PageLink[];
|
||||
|
||||
/** Keyed by the owning page's path. */
|
||||
export declare const PAGE_SECTIONS: Record<string, PageSectionLink[]>;
|
||||
|
||||
export declare const NAV_ACTIONS: NavAction[];
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import PageShell from "../components/PageShell.tsx";
|
||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
|
||||
import OrgListVertical from "./sections/OrgList-Vertical.tsx";
|
||||
import OrgListCards from "./sections/OrgList-Card.tsx";
|
||||
|
|
@ -13,7 +13,7 @@ import OrgListCards from "./sections/OrgList-Card.tsx";
|
|||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const SECTIONS = [
|
||||
defineSection({
|
||||
{
|
||||
id: "chapters",
|
||||
title: "Local Chapters",
|
||||
blurb:
|
||||
|
|
@ -22,8 +22,8 @@ const SECTIONS = [
|
|||
background: "#eef9fb",
|
||||
Component: OrgListMap,
|
||||
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "regions",
|
||||
title: "Unity Regions",
|
||||
blurb:
|
||||
|
|
@ -40,8 +40,8 @@ const SECTIONS = [
|
|||
{ key: "international", title: "International Unity Regions" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "partners",
|
||||
title: "Partner Organizations",
|
||||
blurb:
|
||||
|
|
@ -54,7 +54,7 @@ const SECTIONS = [
|
|||
pageLabel: "Partner page",
|
||||
empty: "· Partner organizations coming soon ·",
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityPage() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||
|
|
@ -31,7 +31,6 @@ import {
|
|||
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
||||
import { personPhoto } from '../lib/media.ts'
|
||||
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||
import { occurrenceLabel, seriesLabel, seriesTimes, upcomingOccurrences } from '../lib/eventSeries.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
|
@ -86,7 +85,7 @@ export default function EventDetail() {
|
|||
const accent = event.color || TEAL
|
||||
const groups = peopleGroups(event.people, accent)
|
||||
|
||||
const sections: ShellSection[] = [
|
||||
const sections = [
|
||||
{
|
||||
id: 'about',
|
||||
title: event.theme || 'About',
|
||||
|
|
@ -128,46 +127,6 @@ export default function EventDetail() {
|
|||
},
|
||||
]
|
||||
|
||||
// A cancelled series has no next meeting, whatever its dates say.
|
||||
const upcoming =
|
||||
event.status === 'cancelled'
|
||||
? []
|
||||
: upcomingOccurrences(event.series, event.starts_on, event.ends_on)
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
const times = seriesTimes(event.series)
|
||||
|
||||
sections.push({
|
||||
id: 'dates',
|
||||
title: 'Upcoming dates',
|
||||
blurb: seriesLabel(event.series, event.starts_on) ?? undefined,
|
||||
accent,
|
||||
background: '#eef9fb',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{upcoming.map((date) => (
|
||||
<li
|
||||
key={date}
|
||||
className="rounded-xl border-l-4 bg-white px-5 py-3"
|
||||
style={{ borderColor: accent }}
|
||||
>
|
||||
<p className="font-semibold" style={{ color: accent }}>
|
||||
{occurrenceLabel(date)}
|
||||
</p>
|
||||
{times && (
|
||||
<p className="text-sm" style={{ color: BODY }}>
|
||||
{times}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (groups.length > 0) {
|
||||
sections.push({
|
||||
id: 'people',
|
||||
|
|
@ -238,12 +197,7 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
|||
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
||||
(event.is_online ? 'Online' : null)
|
||||
|
||||
const schedule = seriesLabel(event.series, event.starts_on)
|
||||
// A series with no label of its own is described by its schedule
|
||||
// rather than by a start-to-end range that reads like one long
|
||||
// gathering.
|
||||
const when =
|
||||
event.date_label || (schedule ? null : dateRange(event.starts_on, event.ends_on))
|
||||
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
|
|
@ -273,7 +227,6 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
|||
)}
|
||||
|
||||
{when && <span style={{ color: BODY }}>{when}</span>}
|
||||
{schedule && <span style={{ color: BODY }}>{schedule}</span>}
|
||||
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||
{event.is_online && where !== 'Online' && (
|
||||
<span style={{ color: BODY }}>Online too</span>
|
||||
|
|
|
|||
|
|
@ -1,132 +1,477 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
HOME — /
|
||||
import { useState, useEffect } from "react";
|
||||
import nguLogo from "../assets/NGU_Logo.svg";
|
||||
import fallLogo from "../assets/Fall Logo.svg";
|
||||
import nguLogo_WhiteBG from "../assets/NGU_Logo_WhiteBG.svg";
|
||||
|
||||
Not a PageShell page: the front page has no title bar, and each
|
||||
band draws its own heading in its own style.
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
Driven by the admin's Front page editor through GET /front-page.
|
||||
The hero comes first, always. After it, the bands in the order
|
||||
the admin dragged them into, minus any they hid. Which component
|
||||
draws a band is decided here, in SECTIONS, keyed on the same list
|
||||
the CHECK in migration 017 holds — the database says "retreats,
|
||||
third, called National Retreats"; this file says what a retreats
|
||||
band looks like.
|
||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FEDA75" />
|
||||
<stop offset="25%" stopColor="#FA7E1E" />
|
||||
<stop offset="50%" stopColor="#D62976" />
|
||||
<stop offset="75%" stopColor="#962FBF" />
|
||||
<stop offset="100%" stopColor="#4F5BD5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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>
|
||||
);
|
||||
|
||||
A band with nothing to show draws nothing: the countdown with no
|
||||
upcoming event, the numbers with no numbers, the timeline with
|
||||
nothing featured, the pathfinder with no paths.
|
||||
const FacebookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" 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>
|
||||
);
|
||||
|
||||
If /front-page fails, the hero still draws (empty) and the error
|
||||
takes the place of the bands, with a retry. There's deliberately
|
||||
no default page to fall back to: it would look fine and hide a
|
||||
broken server.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
const DiscordIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" 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>
|
||||
);
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
{/* 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 },
|
||||
]
|
||||
|
||||
import HeroStage from './sections/home/HeroStage.tsx'
|
||||
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
|
||||
import RetreatsBand from './sections/home/RetreatsBand.tsx'
|
||||
import CalendarBand from './sections/home/CalendarBand.tsx'
|
||||
import StatsBand from './sections/home/StatsBand.tsx'
|
||||
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
|
||||
import Pathfinder from './sections/home/Pathfinder.tsx'
|
||||
import {
|
||||
useFrontPage,
|
||||
type FrontPage,
|
||||
type FrontPageSection,
|
||||
type FrontPageSectionKey,
|
||||
} from '../lib/useFrontPage.ts'
|
||||
import './sections/home/home.css'
|
||||
const Footer_Links = [
|
||||
{ label: "Privacy Policy", href: "#"},
|
||||
{ label: "Terms of Service", href: "#"},
|
||||
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
|
||||
]
|
||||
|
||||
type BandProps = {
|
||||
page: FrontPage
|
||||
section: FrontPageSection
|
||||
title: string
|
||||
/** Position among the visible bands, 0 = straight after the hero. */
|
||||
position: number
|
||||
const EVENTS = [
|
||||
{
|
||||
id: "spring-2025",
|
||||
title: "Spring Retreat 2026",
|
||||
theme: "Altering Intertia",
|
||||
date: "March/April 2026",
|
||||
location: "Unity Village, MO",
|
||||
image: fallLogo, // e.g. springLogo
|
||||
color: "#f1c2fe",
|
||||
gradient:
|
||||
"linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
|
||||
desc_a:
|
||||
"A weekend of connection, workshops, and community for young adults across the Unity movement.",
|
||||
desc_b: null,
|
||||
status: "past",
|
||||
links: [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fall-retreat-2026",
|
||||
title: "Fall Retreat 2026",
|
||||
theme: "Consciousness Creates",
|
||||
date: "November 12-15th, 2026",
|
||||
location: "Unity Village, MO",
|
||||
image: fallLogo,
|
||||
color: "#b89421",
|
||||
gradient:
|
||||
"linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
|
||||
desc_a:
|
||||
"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 to your highest self.",
|
||||
desc_b: "Registration starting at $150, and $75 lodging cost.",
|
||||
status: "upcoming",
|
||||
links: [
|
||||
{ label: "Register Now!", link: "https://ngu.churchcenter.com/registrations/events/3761999" },
|
||||
{ label: "Scholarship Application", link: "https://ngu.churchcenter.com/people/forms/1261992" },
|
||||
{ label: "Volunteer", link: "https://ngu.churchcenter.com/people/forms/1176908" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "spring-recharge-2027",
|
||||
title: "Spring Recharge 2027",
|
||||
theme: "TBD",
|
||||
date: "March 6th, 2027",
|
||||
location: "Online",
|
||||
image: null, // e.g. eventLogo
|
||||
color: "#138ba0",
|
||||
gradient:
|
||||
null,
|
||||
desc_a:
|
||||
"One-day online event to reconnect in the spring.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "spring-service-2027",
|
||||
title: "Service Week 2027",
|
||||
theme: "Leadership & Service",
|
||||
date: "April 4-9th, 2027",
|
||||
location: "Unity Village, MO",
|
||||
image: null, // e.g. eventLogo
|
||||
color: "#138ba0",
|
||||
gradient:
|
||||
null,
|
||||
desc_a:
|
||||
"Join us at beautiful Unity Village for a week of leadership development and service projects.",
|
||||
desc_b: null,
|
||||
status: "upcoming",
|
||||
links: [
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
/* Default headings for a band the admin didn't title, and how each
|
||||
one renders. The ids are the page anchors — #connect is what the
|
||||
hero's second button points at out of the box. */
|
||||
const SECTIONS: Record<
|
||||
FrontPageSectionKey,
|
||||
{ title: string; render: (props: BandProps) => ReactNode }
|
||||
> = {
|
||||
countdown: {
|
||||
title: 'Next up',
|
||||
render: ({ page, position }) =>
|
||||
page.countdown && <NextEventCountdown event={page.countdown} overlap={position === 0} />,
|
||||
},
|
||||
retreats: {
|
||||
title: 'National Retreats',
|
||||
render: ({ section, title }) => (
|
||||
<RetreatsBand id="retreats" title={title} blurb={section.blurb} />
|
||||
),
|
||||
},
|
||||
calendar: {
|
||||
title: 'What’s on',
|
||||
render: ({ section, title }) => (
|
||||
<CalendarBand id="calendar" title={title} blurb={section.blurb} />
|
||||
),
|
||||
},
|
||||
stats: {
|
||||
title: 'By the numbers',
|
||||
render: ({ page, section, title }) =>
|
||||
page.stats.length > 0 && (
|
||||
<StatsBand id="numbers" title={title} blurb={section.blurb} stats={page.stats} />
|
||||
),
|
||||
},
|
||||
timeline: {
|
||||
title: 'Our story so far',
|
||||
render: ({ section, title }) => (
|
||||
<FeaturedTimelineRail id="story" title={title} blurb={section.blurb} />
|
||||
),
|
||||
},
|
||||
connect: {
|
||||
title: 'Find your way in',
|
||||
render: ({ page, section, title }) => (
|
||||
<Pathfinder id="connect" title={title} blurb={section.blurb} paths={page.paths} />
|
||||
),
|
||||
},
|
||||
}
|
||||
const START_INDEX = (() => {
|
||||
const i = EVENTS.findIndex(e => e.status === "upcoming");
|
||||
return i === -1 ? EVENTS.length - 1 : i;
|
||||
})();
|
||||
|
||||
export default function Home() {
|
||||
const { data: page, error, reload } = useFrontPage()
|
||||
const TEAL = "#138ba0";
|
||||
|
||||
const CARD = "min(48rem, 90vw)"; // the card itself — your original max-w-3xl
|
||||
const GAP = "5rem"; // space between cards ← this is your knob
|
||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
||||
const FADE_DIST = "18rem";
|
||||
|
||||
const EDGE_FADE = `linear-gradient(to right,
|
||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
||||
black calc(50% - ${HALF_SLIDE}),
|
||||
black calc(50% + ${HALF_SLIDE}),
|
||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||
|
||||
{/* Functions */}
|
||||
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
|
||||
return (
|
||||
<>
|
||||
<HeroStage hero={page?.hero ?? null} />
|
||||
|
||||
{error && (
|
||||
<section className="px-6 py-24 text-center">
|
||||
<p className="text-[#b3261e]">Couldn’t load the front page. {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-4 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] hover:bg-[#eef9fb]"
|
||||
{text.split("").map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="float-anim"
|
||||
style={{ animationDelay: `${-i * step}s` }}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{page?.sections.map((section, position) => {
|
||||
const band = SECTIONS[section.section]
|
||||
// A key the CHECK has gained since this file was written.
|
||||
if (!band) return null
|
||||
|
||||
|
||||
export default function Home() {
|
||||
{/* Other useState consts and Functions*/}
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const [index, setIndex] = useState(START_INDEX);
|
||||
|
||||
const event = EVENTS[index];
|
||||
const isPast = event.status === "past";
|
||||
const currentColor = event.color || TEAL;
|
||||
|
||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||||
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
|
||||
|
||||
const arrowStyle = enabled => ({
|
||||
border: `1px solid ${TEAL}`,
|
||||
background: "rgba(255,255,255,0.6)",
|
||||
color: enabled ? TEAL : "#b8c6c9",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
{/* Start of Main Content*/}
|
||||
return (
|
||||
<div key={section.section}>
|
||||
{band.render({
|
||||
page,
|
||||
section,
|
||||
title: section.title || band.title,
|
||||
position,
|
||||
})}
|
||||
<>
|
||||
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
|
||||
<section id="hero" 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 ─────────────────────────────────────── */}
|
||||
<section id="about" 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 overflow-hidden" style={{ background: "#eef9fb" }}>
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
|
||||
{isPast ? "Past Events" : "Upcoming Events"}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
|
||||
<div className="relative mb-6">
|
||||
{/* Masked viewport: everything outside the fade gradient is invisible */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
||||
>
|
||||
{/* Sliding track */}
|
||||
<div
|
||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))`,
|
||||
}}
|
||||
>
|
||||
{EVENTS.map((ev, i) => {
|
||||
const active = i === index;
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || TEAL;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: SLIDE,
|
||||
padding: `0 calc(${GAP} / 2)`,
|
||||
cursor: active ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => !active && setIndex(i)}
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<div
|
||||
className="rounded-3xl text-black overflow-hidden shadow-2xl h-full"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: ev.gradient,
|
||||
filter: past ? "saturate(0.75)" : "none",
|
||||
pointerEvents: active ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="p-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<img
|
||||
src={nguLogo_WhiteBG}
|
||||
alt="Next Generation of Unity"
|
||||
className="h-15 w-auto mb-6"
|
||||
/>
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
<p className="text-2xl">{ev.date}</p>
|
||||
<p className="text-2xl">{ev.location}</p>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex justify-end items-start">
|
||||
{ev.image && (
|
||||
<img src={ev.image} alt={ev.title} className="h-60" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ev.desc_a && <p className="mb-2 leading-relaxed">{ev.desc_a}</p>}
|
||||
{ev.desc_b && <p className="leading-relaxed">{ev.desc_b}</p>}
|
||||
|
||||
{ev.links.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-8">
|
||||
{ev.links.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 ${color}` }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
past ? (
|
||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
||||
This event has concluded, thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
||||
Registration has not opened yet, follow our instagram for more details.
|
||||
</p>
|
||||
<a href="https://instagram.com/nextgenerationunity" target="_blank" rel="noopener noreferrer" className="ig-link mt-4 w-fit mx-auto flex items-center justify-center gap-3 py-3 px-4 rounded-xl font-700 transition-all duration-200 hover:scale-[1.02]" style={{ border: `1px solid ${color}`, color }}>
|
||||
<InstagramIcon id={`ig-${ev.id}`} />
|
||||
@nextgenerationunity
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrows — outside the masked element so they never fade */}
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous event"
|
||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index > 0)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={index === EVENTS.length - 1}
|
||||
aria-label="Next event"
|
||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index < EVENTS.length - 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Dot indicators */}
|
||||
<div className="flex justify-center gap-2 mb-10">
|
||||
{EVENTS.map((e, i) => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Go to ${e.title}`}
|
||||
className="h-2.5 rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: i === index ? "1.5rem" : "0.625rem",
|
||||
background: i === index ? currentColor : "#b8c6c9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[#138ba0] font-600 text-sm">
|
||||
· More events coming soon, stay connected for announcements ·
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/* #Connect -------------------------------------------- */}
|
||||
<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">
|
||||
<div className="inline-flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://ngu.churchcenter.com/calendar?view=gallery"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCalendar(!showCalendar)}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105"
|
||||
style={{ border: "2px solid #138ba0", color: "#138ba0", background: "transparent", fontFamily: "Outfit,sans-serif" }}
|
||||
>
|
||||
{showCalendar ? "▲ Hide Calendar" : "▼ Show Calendar Here"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCalendar && (
|
||||
<div className="mt-8 mx-auto max-w-4xl rounded-2xl shadow-lg p-6" style={{ border: "2px solid #138ba0" }}>
|
||||
<iframe
|
||||
src="https://ngu.churchcenter.com/calendar?embed=true&view=month"
|
||||
title="NGU Calendar"
|
||||
className="w-full planning-center-calender-embed"
|
||||
style={{ height: "700px", border: "none" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,13 +331,7 @@ function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
|||
</a>
|
||||
))}
|
||||
|
||||
{/* website and email arrive as bare strings (splitLinks in
|
||||
shape.js); socials are whole link rows. */}
|
||||
{[
|
||||
org.website ? { url: org.website, label: 'Website' } : null,
|
||||
org.email ? { url: `mailto:${org.email}`, label: org.email } : null,
|
||||
...org.socials,
|
||||
]
|
||||
{[org.website, org.email, ...org.socials]
|
||||
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||
.map((link) => (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,363 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PERSON DETAIL — /people/:id
|
||||
|
||||
Everything public that points at one person, gathered by
|
||||
GET /people/:id in people.js. Visibility is decided there, the
|
||||
same way each list's own page decides it, so nothing here
|
||||
filters.
|
||||
|
||||
Roles are current and past. A roster only ever wants who holds
|
||||
a seat now; a person's record is also where "served on the board
|
||||
2018–2022" belongs, so ended affiliations list under Previously.
|
||||
|
||||
public_phone is never sent. The email is the one contact detail
|
||||
the page offers.
|
||||
|
||||
Sections after About alternate background in the order they
|
||||
appear, so a person with no roles doesn't get two tinted bands
|
||||
in a row.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import {
|
||||
usePerson,
|
||||
type PersonEvent,
|
||||
type PersonRecord,
|
||||
type PersonRole,
|
||||
} from '../lib/useContent.ts'
|
||||
import { awardHref, eventHref, orgHref, teamHref } from '../lib/hrefs.ts'
|
||||
import { initials, personPhoto } from '../lib/media.ts'
|
||||
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
const BACKGROUNDS = ['#ffffff', '#eef9fb']
|
||||
|
||||
/* affiliations.role, for a row with no title of its own. */
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
lead: 'Lead',
|
||||
board: 'Board member',
|
||||
staff: 'Staff',
|
||||
volunteer: 'Volunteer',
|
||||
member: 'Member',
|
||||
}
|
||||
|
||||
const capitalize = (word: string) =>
|
||||
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||
|
||||
export default function PersonDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: person, loading, error, notFound, reload } = usePerson(id)
|
||||
|
||||
if (!person) {
|
||||
return (
|
||||
<PageState
|
||||
loading={loading}
|
||||
error={error}
|
||||
notFound={notFound}
|
||||
onRetry={reload}
|
||||
noun="person"
|
||||
backTo="/leadership"
|
||||
backLabel="Leadership"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = TEAL
|
||||
const current = person.roles.filter((role) => !role.ended_on)
|
||||
const previous = person.roles.filter((role) => role.ended_on)
|
||||
|
||||
const sections: ShellSection[] = [
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
accent,
|
||||
background: BACKGROUNDS[0],
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
<Facts person={person} accent={accent} />
|
||||
|
||||
{[...person.bio, ...person.description].map((paragraph, index) => (
|
||||
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<ContentBlocks blocks={person.blocks} accent={accent} />
|
||||
</div>
|
||||
|
||||
<Contact person={person} accent={accent} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (person.roles.length > 0) {
|
||||
sections.push({
|
||||
id: 'roles',
|
||||
title: 'Roles',
|
||||
accent,
|
||||
background: BACKGROUNDS[sections.length % 2],
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
{current.length > 0 && <RoleList roles={current} accent={accent} />}
|
||||
|
||||
{previous.length > 0 && (
|
||||
<div>
|
||||
{current.length > 0 && (
|
||||
<h3 className="mb-4 text-lg font-semibold" style={{ color: accent }}>
|
||||
Previously
|
||||
</h3>
|
||||
)}
|
||||
<RoleList roles={previous} accent={accent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (person.events.length > 0) {
|
||||
sections.push({
|
||||
id: 'events',
|
||||
title: 'Events',
|
||||
accent,
|
||||
background: BACKGROUNDS[sections.length % 2],
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<ul className="space-y-5">
|
||||
{person.events.map((event) => (
|
||||
<li key={event.id}>
|
||||
<EventRow event={event} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (person.awards.length > 0) {
|
||||
sections.push({
|
||||
id: 'awards',
|
||||
title: 'Awards',
|
||||
accent,
|
||||
background: BACKGROUNDS[sections.length % 2],
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||
{person.awards.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.award.id}:${entry.awarded_on ?? index}`}
|
||||
className="border-l-2 pl-5"
|
||||
style={{ borderColor: accent }}
|
||||
>
|
||||
<p className="font-semibold" style={{ color: accent }}>
|
||||
<Link to={awardHref(entry.award.id)} className="hover:underline">
|
||||
{entry.award.name}
|
||||
</Link>
|
||||
</p>
|
||||
{(entry.awarded_on || entry.event) && (
|
||||
<p className="text-sm" style={{ color: BODY }}>
|
||||
{year(entry.awarded_on)}
|
||||
{entry.event && (
|
||||
<>
|
||||
{entry.awarded_on && ' · '}
|
||||
<Link
|
||||
to={eventHref(entry.event.id)}
|
||||
className="hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{entry.event.title}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{entry.citation && (
|
||||
<p className="mt-1 max-w-2xl italic leading-relaxed" style={{ color: BODY }}>
|
||||
{entry.citation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={person.name} intro={person.tagline ?? undefined} sections={sections} />
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The strip of facts under the heading ────────────────────── */
|
||||
|
||||
function Facts({ person, accent }: { person: PersonRecord; accent: string }) {
|
||||
const photo = personPhoto(person.photo)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
{photo ? (
|
||||
<img
|
||||
src={photo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-24 w-24 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-24 w-24 shrink-0 items-center justify-center rounded-full text-2xl font-bold text-white"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{initials(person.name)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{person.pronouns && <span style={{ color: BODY }}>{person.pronouns}</span>}
|
||||
|
||||
{person.org && (
|
||||
<Link
|
||||
to={orgHref(person.org.id, person.org.kind)}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{person.org.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{person.location_label && <span style={{ color: BODY }}>{person.location_label}</span>}
|
||||
|
||||
<Link to="/leadership" className="ml-auto hover:underline" style={{ color: accent }}>
|
||||
Leadership
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Contact({ person, accent }: { person: PersonRecord; accent: string }) {
|
||||
const outlined = [
|
||||
person.website ? { url: person.website, label: 'Website' } : null,
|
||||
person.public_email
|
||||
? { url: `mailto:${person.public_email}`, label: person.public_email }
|
||||
: null,
|
||||
...person.socials,
|
||||
].filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||
|
||||
if (person.links.length === 0 && outlined.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{person.links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{outlined.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Roles ───────────────────────────────────────────────────── */
|
||||
|
||||
function RoleList({ roles, accent }: { roles: PersonRole[]; accent: string }) {
|
||||
return (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{roles.map((role, index) => (
|
||||
<li
|
||||
key={`${role.org.id}:${role.team?.id ?? ''}:${role.title ?? role.role}:${index}`}
|
||||
className="rounded-xl border-l-4 bg-white px-5 py-3"
|
||||
style={{ borderColor: accent }}
|
||||
>
|
||||
<p className="font-semibold" style={{ color: accent }}>
|
||||
{role.title || ROLE_LABEL[role.role] || capitalize(role.role)}
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: BODY }}>
|
||||
{role.team && (
|
||||
<>
|
||||
<Link to={teamHref(role.team.id)} className="hover:underline">
|
||||
{role.team.name}
|
||||
</Link>
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
<Link to={orgHref(role.org.id, role.org.kind)} className="hover:underline">
|
||||
{role.org.name}
|
||||
</Link>
|
||||
</p>
|
||||
{tenure(role) && (
|
||||
<p className="text-xs" style={{ color: BODY }}>
|
||||
{tenure(role)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/* "2018 – 2022", "Since 2021", "Until 2019", or null. Years only:
|
||||
affiliation dates are often backfilled from memory. */
|
||||
function tenure(role: PersonRole): string | null {
|
||||
const from = year(role.started_on)
|
||||
const to = year(role.ended_on)
|
||||
if (from && to) return from === to ? from : `${from} – ${to}`
|
||||
if (from) return `Since ${from}`
|
||||
if (to) return `Until ${to}`
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── Events ──────────────────────────────────────────────────── */
|
||||
|
||||
function EventRow({ event, accent }: { event: PersonEvent; accent: string }) {
|
||||
const when = event.date_label || year(event.starts_on)
|
||||
const capacities = event.roles
|
||||
.map((role) => role.title || capitalize(role.role))
|
||||
.join(', ')
|
||||
|
||||
return (
|
||||
<div className="border-l-2 pl-5" style={{ borderColor: accent }}>
|
||||
<p className="font-semibold" style={{ color: accent }}>
|
||||
<Link to={eventHref(event.id)} className="hover:underline">
|
||||
{event.title}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: BODY }}>
|
||||
{[capacities, eventTypeLabel(event.event_type), when].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Dates here may be partial ('2019', '2019-06'), so take the
|
||||
leading year rather than parsing. */
|
||||
function year(date?: string | null): string | null {
|
||||
if (!date) return null
|
||||
const match = /^(\d{4})/.exec(date)
|
||||
return match ? match[1] : date
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import PageShell from "../components/PageShell.tsx";
|
||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
|
|
@ -35,10 +35,10 @@ const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
|||
|
||||
/* Pinned on every band. Named rather than repeated so turning this
|
||||
page into "everything, filtered" later is one deletion. */
|
||||
const RETREATS = { type: "retreat" } as const;
|
||||
const RETREATS = { type: "retreat" };
|
||||
|
||||
const SECTIONS = [
|
||||
defineSection({
|
||||
{
|
||||
id: "national",
|
||||
title: "National Retreats",
|
||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
||||
|
|
@ -47,8 +47,8 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "national", ...RETREATS },
|
||||
views: { ...CARD_VIEWS, default: "carousel" },
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "regional",
|
||||
title: "Regional Retreats",
|
||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
||||
|
|
@ -57,8 +57,8 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "regional", ...RETREATS },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "partner",
|
||||
title: "Partner Events",
|
||||
blurb: "Retreats hosted by organizations we collaborate with.",
|
||||
|
|
@ -67,13 +67,13 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "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.
|
||||
|
||||
defineSection({
|
||||
{
|
||||
id: "local",
|
||||
title: "Local Events",
|
||||
blurb: "Hosted by individual chapters.",
|
||||
|
|
@ -82,8 +82,8 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "local", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "international",
|
||||
title: "International Events",
|
||||
blurb: "Gatherings beyond the US.",
|
||||
|
|
@ -92,8 +92,8 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "international", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
}),
|
||||
defineSection({
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
title: "Other Events",
|
||||
blurb: "Everything that doesn't fit the categories above.",
|
||||
|
|
@ -102,7 +102,7 @@ const SECTIONS = [
|
|||
Component: EventListCards,
|
||||
props: { section: "other", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
}),
|
||||
},
|
||||
|
||||
*/
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,37 +25,9 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
|||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
/* feedback.status's CHECK values, in triage order. */
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
|
||||
type FeedbackStatus = (typeof STATUSES)[number];
|
||||
|
||||
/* The columns GET /api/admin/feedback selects. */
|
||||
type FeedbackRow = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
feedback_type: string;
|
||||
message: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
page_path: string | null;
|
||||
section_id: string | null;
|
||||
status: FeedbackStatus;
|
||||
admin_note: string | null;
|
||||
};
|
||||
|
||||
type FeedbackPage = {
|
||||
feedback: FeedbackRow[];
|
||||
/** Unfiltered, one per status. */
|
||||
counts: Record<FeedbackStatus, number>;
|
||||
nextCursor: number | null;
|
||||
};
|
||||
|
||||
type FeedbackChanges = { status?: FeedbackStatus; admin_note?: string };
|
||||
|
||||
type StatusFilter = FeedbackStatus | "all";
|
||||
|
||||
const STATUS_STYLE: Record<FeedbackStatus, string> = {
|
||||
const STATUS_STYLE = {
|
||||
new: "bg-[#138ba0] text-white",
|
||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||
|
|
@ -65,7 +37,7 @@ const STATUS_STYLE: Record<FeedbackStatus, string> = {
|
|||
|
||||
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||
// won't parse without the T and the Z.
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value) {
|
||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
|
|
@ -73,34 +45,26 @@ function formatDate(value: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function locationOf(row: FeedbackRow) {
|
||||
function locationOf(row) {
|
||||
if (!row.page_path) return "Not page-specific";
|
||||
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||
}
|
||||
|
||||
/* ── One submission ──────────────────────────────────────────── */
|
||||
|
||||
type FeedbackCardProps = {
|
||||
row: FeedbackRow;
|
||||
onChange: (row: FeedbackRow) => void;
|
||||
onRemove: (row: FeedbackRow) => void;
|
||||
canWrite: boolean;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
async function save(changes: FeedbackChanges) {
|
||||
async function save(changes) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
|
||||
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||
onChange(data.feedback);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||
|
|
@ -177,8 +141,7 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: Feedback
|
|||
id={`status-${row.id}`}
|
||||
value={row.status}
|
||||
disabled={busy}
|
||||
// The options are STATUSES, so the value is always one of them.
|
||||
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
|
||||
onChange={(e) => save({ status: e.target.value })}
|
||||
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
|
|
@ -265,22 +228,22 @@ export default function AdminFeedback() {
|
|||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<StatusFilter>("new");
|
||||
const [status, setStatus] = useState("new");
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState(""); // applied, not typed
|
||||
|
||||
const [rows, setRows] = useState<FeedbackRow[]>([]);
|
||||
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Minimums, not equality — see lib/roles.ts.
|
||||
const canWrite = roleCanWrite(user);
|
||||
const canRemove = canDelete(user);
|
||||
|
||||
const load = useCallback(
|
||||
async (before: number | null = null) => {
|
||||
async (before = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -290,7 +253,7 @@ export default function AdminFeedback() {
|
|||
if (before) params.set("before", String(before));
|
||||
|
||||
try {
|
||||
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||
setCounts(data.counts);
|
||||
setCursor(data.nextCursor);
|
||||
|
|
@ -314,7 +277,7 @@ export default function AdminFeedback() {
|
|||
load();
|
||||
}, [load]);
|
||||
|
||||
function replaceRow(updated: FeedbackRow) {
|
||||
function replaceRow(updated) {
|
||||
setRows((prev) =>
|
||||
prev
|
||||
.map((row) => (row.id === updated.id ? updated : row))
|
||||
|
|
@ -327,7 +290,7 @@ export default function AdminFeedback() {
|
|||
|
||||
// The deleted row is passed whole rather than by id: its status
|
||||
// is what says which tab count to drop.
|
||||
function removeRow(removed: FeedbackRow) {
|
||||
function removeRow(removed) {
|
||||
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -335,7 +298,7 @@ export default function AdminFeedback() {
|
|||
}));
|
||||
}
|
||||
|
||||
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
|
||||
const tabs = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,18 +18,17 @@
|
|||
record) wants to be a separate component that fails on its own.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { SITE_VERSION } from "../../lib/version.ts";
|
||||
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } from "./adminNav.js";
|
||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
|
||||
|
||||
/* One card. The title link is stretched over the whole card with
|
||||
`after:absolute`, which makes the card clickable without nesting
|
||||
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
||||
they stay separately clickable. */
|
||||
function NavCard({ item }: { item: AdminNavItem }) {
|
||||
function NavCard({ item }) {
|
||||
const to = target(item);
|
||||
|
||||
// Drop the child that just repeats the card's own destination —
|
||||
|
|
@ -76,15 +75,7 @@ function NavCard({ item }: { item: AdminNavItem }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CardBlock({
|
||||
title,
|
||||
blurb,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
blurb?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function CardBlock({ title, blurb, children }) {
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -96,7 +87,7 @@ function CardBlock({
|
|||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
function PanelSection({ title, children }) {
|
||||
return (
|
||||
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
||||
|
|
@ -116,7 +107,7 @@ const QUICK_ADD = [
|
|||
|
||||
export default function AdminHome() {
|
||||
const { user } = useAuth();
|
||||
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
|
||||
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ export default function AdminLayout() {
|
|||
// What the page below has published about itself — a record
|
||||
// name, or null on a list. setDetail is stable so publishing
|
||||
// can't loop.
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const stableSet = useCallback((value: string | null) => setDetail(value), []);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const stableSet = useCallback((value) => setDetail(value), []);
|
||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
starts working.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
|
|
@ -29,12 +29,12 @@ export default function AdminLogin() {
|
|||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,54 +24,23 @@
|
|||
the reason shows up before the click rather than after it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { del, get, patch } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ROLES, ROLE_LABELS, ROLE_NOTES, type Role } from "../../lib/roles.ts";
|
||||
|
||||
/* An admin_users row as panel.js selects it, with its live session count. */
|
||||
type PanelUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
sessions: number;
|
||||
};
|
||||
|
||||
/* GET /api/admin/panel/overview. A count is null when its table
|
||||
doesn't exist yet. */
|
||||
type Overview = {
|
||||
system: {
|
||||
schemaVersion: number;
|
||||
dbPath: string | null;
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
uptimeSeconds: number;
|
||||
startedAt: string;
|
||||
sessions: number | null;
|
||||
roles: Role[];
|
||||
};
|
||||
content: Array<{ label: string; count: number | null }>;
|
||||
users: PanelUser[];
|
||||
};
|
||||
|
||||
type UserResponse = { user: PanelUser };
|
||||
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||
|
||||
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||
marker that says so. Left alone, browsers read it as local time
|
||||
and last-login drifts by the timezone offset. */
|
||||
function when(value: string | null | undefined) {
|
||||
function when(value) {
|
||||
if (!value) return "—";
|
||||
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function uptime(seconds: number | null | undefined) {
|
||||
function uptime(seconds) {
|
||||
if (seconds == null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
|
|
@ -81,15 +50,7 @@ function uptime(seconds: number | null | undefined) {
|
|||
return `${m}m`;
|
||||
}
|
||||
|
||||
function Block({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function Block({ title, note, children }) {
|
||||
return (
|
||||
<section className="mt-8 first:mt-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -101,7 +62,7 @@ function Block({
|
|||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
||||
|
|
@ -116,23 +77,23 @@ export default function AdminPanel() {
|
|||
const { user: me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||
// the row so a failure on one account doesn't blank the table.
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [rowError, setRowError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await get<Overview>("/admin/panel/overview", { ttl: 0 }));
|
||||
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
|
||||
setError(err.message || "Couldn't load the panel.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -144,7 +105,7 @@ export default function AdminPanel() {
|
|||
|
||||
/* Replace the one row the server returns rather than refetching
|
||||
the whole overview — the counts didn't change. */
|
||||
function mergeUser(updated: PanelUser) {
|
||||
function mergeUser(updated) {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
|
|
@ -155,37 +116,30 @@ export default function AdminPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
async function run(id: number, work: () => Promise<PanelUser>) {
|
||||
async function run(id, work) {
|
||||
setBusyId(id);
|
||||
setRowError(null);
|
||||
try {
|
||||
mergeUser(await work());
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
|
||||
setRowError({ id, message: err.message || "That didn't work." });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = (row: PanelUser, role: Role) =>
|
||||
const changeRole = (row, role) =>
|
||||
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||
|
||||
const setActive = (row, is_active) =>
|
||||
run(
|
||||
row.id,
|
||||
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
|
||||
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||
);
|
||||
|
||||
const setActive = (row: PanelUser, is_active: number) =>
|
||||
run(
|
||||
row.id,
|
||||
async () =>
|
||||
(await patch<UserResponse>(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||
);
|
||||
|
||||
const revoke = (row: PanelUser) =>
|
||||
run(
|
||||
row.id,
|
||||
async () => (await del<UserResponse>(`/admin/panel/users/${row.id}/sessions`)).user,
|
||||
);
|
||||
const revoke = (row) =>
|
||||
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -210,9 +164,6 @@ export default function AdminPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
// Not loading and no error means the overview arrived.
|
||||
if (!data) return null;
|
||||
|
||||
const { system, content, users } = data;
|
||||
const activeSupers = users.filter(
|
||||
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||
|
|
@ -303,7 +254,7 @@ export default function AdminPanel() {
|
|||
<select
|
||||
value={row.role}
|
||||
disabled={locked || busy}
|
||||
onChange={(e) => changeRole(row, e.target.value as Role)}
|
||||
onChange={(e) => changeRole(row, e.target.value)}
|
||||
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||
title={
|
||||
isMe
|
||||
|
|
|
|||
|
|
@ -43,36 +43,26 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { get, post, patch, del, ApiError, type FieldErrors } from "../../lib/api.js";
|
||||
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||
import {
|
||||
ADMIN_ENTITIES,
|
||||
slugify,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type FieldCondition,
|
||||
} from "../../lib/adminSchema.js";
|
||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||
|
||||
/* A foreign key refusing to budge is the most common way a save or
|
||||
delete fails here, and SQLite's own wording explains nothing to
|
||||
whoever is filling in the form. */
|
||||
function friendly(message: string, singular: string): string {
|
||||
function friendly(message, singular) {
|
||||
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
||||
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
type RowResponse = { row: AdminRow };
|
||||
|
||||
type Notice = { tone: "ok" | "error"; text: string; recover?: "reload" };
|
||||
|
||||
export default function EntityEdit() {
|
||||
const { entity: entityKey, id } = useParams();
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -86,18 +76,12 @@ export default function EntityEdit() {
|
|||
// references an event has no name of its own.
|
||||
const autoId = manifest?.idKind === "auto";
|
||||
|
||||
// A singleton has one row, made by its migration: no slug to show
|
||||
// or type, no list to go back to, nothing to delete. The server
|
||||
// refuses create and delete regardless; this only stops offering
|
||||
// them.
|
||||
const singleton = Boolean(manifest?.singleton);
|
||||
|
||||
// Hoisted above the loading guards: the title hook below is a
|
||||
// hook, so it can't sit after an early return, and it needs the
|
||||
// same paths the heading uses.
|
||||
const slugPaths: string[] = Array.isArray(manifest?.slugFrom)
|
||||
const slugPaths = Array.isArray(manifest?.slugFrom)
|
||||
? manifest.slugFrom
|
||||
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
|
||||
: [manifest?.slugFrom].filter(Boolean);
|
||||
|
||||
// Everything before the last path is a qualifier: a fact about
|
||||
// another field rather than something to type. It renders as
|
||||
|
|
@ -107,7 +91,7 @@ export default function EntityEdit() {
|
|||
|
||||
// Empty until every qualifier is chosen, because half a prefix
|
||||
// would be saved into an id that then never matches.
|
||||
const prefixOf = (source: AdminRow | null) => {
|
||||
const prefixOf = (source) => {
|
||||
if (qualifierPaths.length === 0) return "";
|
||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||
if (parts.some((part) => !part)) return "";
|
||||
|
|
@ -119,9 +103,9 @@ export default function EntityEdit() {
|
|||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||
: "";
|
||||
|
||||
const tailOf = (source: AdminRow | null) => {
|
||||
const tailOf = (source) => {
|
||||
const prefix = prefixOf(source);
|
||||
const value = String(source?.id ?? "");
|
||||
const value = source?.id ?? "";
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
};
|
||||
|
||||
|
|
@ -130,27 +114,24 @@ export default function EntityEdit() {
|
|||
// names the field to read instead.
|
||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||
|
||||
// Blank when neither is set yet, which reads as "nothing to name".
|
||||
const headingOf = (row: AdminRow) => String(getPath(row, headingPath) || row.id || "");
|
||||
|
||||
const [form, setForm] = useState<AdminRow | null>(null);
|
||||
const [options, setOptions] = useState<AdminOptions>({});
|
||||
const [errors, setErrors] = useState<Partial<FieldErrors>>({});
|
||||
const [message, setMessage] = useState<Notice | null>(null);
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
// The last state the server confirmed. Everything else compares
|
||||
// against this to decide whether there's anything to lose.
|
||||
const baseline = useRef<string | null>(null);
|
||||
const baseline = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
|
||||
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||
setOptions(opts.options);
|
||||
|
||||
if (isNew) {
|
||||
|
|
@ -160,12 +141,12 @@ export default function EntityEdit() {
|
|||
// server's column defaults apply to whatever isn't filled in.
|
||||
// No id key for an auto entity: the table assigns it, and
|
||||
// sending "" would be an explicit value rather than an absence.
|
||||
const blank: AdminRow = autoId ? {} : { id: "" };
|
||||
const blank = autoId ? {} : { id: "" };
|
||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||
setForm(blank);
|
||||
baseline.current = JSON.stringify(blank);
|
||||
} else {
|
||||
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
}
|
||||
|
|
@ -200,7 +181,7 @@ export default function EntityEdit() {
|
|||
: isNew
|
||||
? `New ${manifest.singular}`
|
||||
: form
|
||||
? headingOf(form)
|
||||
? getPath(form, headingPath) || form.id
|
||||
: null,
|
||||
);
|
||||
|
||||
|
|
@ -208,7 +189,7 @@ export default function EntityEdit() {
|
|||
// Router entirely, so the only hook available is this one.
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const warn = (event: BeforeUnloadEvent) => {
|
||||
const warn = (event) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
|
|
@ -221,19 +202,18 @@ export default function EntityEdit() {
|
|||
|
||||
/* ── Heading ───────────────────────────────────────────────── */
|
||||
|
||||
const heading = singleton ? manifest.label : headingOf(form);
|
||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
||||
const heading = getPath(form, headingPath) || form.id;
|
||||
|
||||
const children = manifest.children ?? [];
|
||||
|
||||
/* ── Actions ───────────────────────────────────────────────── */
|
||||
|
||||
const leave = (to: string) => {
|
||||
const leave = (to) => {
|
||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const change = (path: string, value: unknown) => {
|
||||
const change = (path, value) => {
|
||||
setForm((prev) => {
|
||||
let next = setPath(prev, path, value);
|
||||
// Recompose the id whenever one of its sources moves. The
|
||||
|
|
@ -255,20 +235,20 @@ export default function EntityEdit() {
|
|||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = isNew
|
||||
? await post<RowResponse>(`/admin/${manifest.key}`, form)
|
||||
: await patch<RowResponse>(`/admin/${manifest.key}/${id}`, form);
|
||||
? await post(`/admin/${manifest.key}`, form)
|
||||
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
setMessage({ tone: "ok", text: "Saved." });
|
||||
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true });
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
|
||||
|
|
@ -293,9 +273,9 @@ export default function EntityEdit() {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
async function remove() {
|
||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||
|
||||
try {
|
||||
|
|
@ -311,13 +291,12 @@ export default function EntityEdit() {
|
|||
: "Couldn't delete that.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
|
||||
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
{!singleton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => leave(`/admin/${manifest.key}`)}
|
||||
|
|
@ -325,7 +304,6 @@ export default function EntityEdit() {
|
|||
>
|
||||
← {manifest.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
||||
{isNew ? `New ${manifest.singular}` : heading}
|
||||
|
|
@ -334,18 +312,12 @@ export default function EntityEdit() {
|
|||
{/* Slug. An auto-id entity has nothing to ask for on create, and
|
||||
nothing editable afterwards — so it gets a plain line rather
|
||||
than a disabled box pretending to be a field. */}
|
||||
{singleton ? (
|
||||
updatedAt && (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<p className="text-sm text-[#4a6b72]">Last saved {updatedAt}</p>
|
||||
</div>
|
||||
)
|
||||
) : autoId ? (
|
||||
{autoId ? (
|
||||
!isNew && (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<p className="text-sm text-[#4a6b72]">
|
||||
{manifest.idLabel} #{String(form.id)}
|
||||
{updatedAt && <> · last saved {updatedAt}</>}
|
||||
{manifest.idLabel} #{form.id}
|
||||
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -372,9 +344,9 @@ export default function EntityEdit() {
|
|||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||
}}
|
||||
/>
|
||||
{!isNew && updatedAt && (
|
||||
{!isNew && form.updated_at && (
|
||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||
Last saved {updatedAt}
|
||||
Last saved {form.updated_at}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -416,7 +388,7 @@ export default function EntityEdit() {
|
|||
<Repeater
|
||||
key={child.key}
|
||||
spec={child}
|
||||
rows={form[child.key] as AdminRow[] | undefined}
|
||||
rows={form[child.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${child.key}.`}
|
||||
|
|
@ -439,7 +411,7 @@ export default function EntityEdit() {
|
|||
>
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||
</button>
|
||||
{!isNew && !singleton && canDelete && (
|
||||
{!isNew && canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
|
|
|
|||
|
|
@ -13,29 +13,24 @@
|
|||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { get, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import {
|
||||
ADMIN_ENTITIES,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type ListFilter,
|
||||
} from "../../lib/adminSchema.js";
|
||||
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
|
||||
export default function EntityList() {
|
||||
const { entity: entityKey } = useParams();
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [rows, setRows] = useState<AdminRow[]>([]);
|
||||
const [options, setOptions] = useState<AdminOptions>({});
|
||||
const [rows, setRows] = useState([]);
|
||||
const [options, setOptions] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
|
||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||
|
|
@ -46,13 +41,13 @@ export default function EntityList() {
|
|||
const canWrite = atLeast(user, "editor");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest || manifest.singleton) return;
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [list, opts] = await Promise.all([
|
||||
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
|
||||
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get("/admin/options", { ttl: 60_000 }),
|
||||
]);
|
||||
setRows(list.rows);
|
||||
setOptions(opts.options);
|
||||
|
|
@ -72,23 +67,18 @@ export default function EntityList() {
|
|||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
}
|
||||
|
||||
// One row, so no list: the tab opens the row.
|
||||
if (manifest.singleton) {
|
||||
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
|
||||
}
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
function setParam(key, value) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function labelFor(filter: ListFilter): Array<readonly [string, string]> {
|
||||
function labelFor(filter, value) {
|
||||
if (filter.optionsFrom) {
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||
}
|
||||
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
|
||||
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -169,7 +159,7 @@ export default function EntityList() {
|
|||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={String(row.id)}
|
||||
key={row.id}
|
||||
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||
>
|
||||
|
|
@ -184,12 +174,10 @@ export default function EntityList() {
|
|||
? row[column.key]
|
||||
? "Yes"
|
||||
: "—"
|
||||
: row[column.key]
|
||||
? String(row[column.key])
|
||||
: "—"}
|
||||
: row[column.key] || "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { atLeast, type Role } from "../../lib/roles.ts";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
import { ADMIN_HOME } from "./adminNav.js";
|
||||
|
||||
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
||||
export default function RequireRole({ role = "superadmin" }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// RequireAuth is already showing its own placeholder above this.
|
||||
|
|
|
|||
45
src/pages/admin/adminNav.d.ts
vendored
45
src/pages/admin/adminNav.d.ts
vendored
|
|
@ -1,45 +0,0 @@
|
|||
/* Types for adminNav.js. */
|
||||
|
||||
import type { AdminUser } from "../../lib/auth.tsx";
|
||||
|
||||
export declare const ADMIN_HOME: string;
|
||||
export declare const ADMIN_PANEL: string;
|
||||
|
||||
export type AdminArea = "home" | "cms" | "panel";
|
||||
|
||||
export declare const AREA_TITLES: Record<AdminArea, string>;
|
||||
|
||||
export type AdminNavLink = {
|
||||
to: string;
|
||||
label: string;
|
||||
/** Only read by the home cards. */
|
||||
blurb?: string;
|
||||
};
|
||||
|
||||
type AdminNavBase = {
|
||||
label: string;
|
||||
blurb?: string;
|
||||
separated?: boolean;
|
||||
superOnly?: boolean;
|
||||
};
|
||||
|
||||
/** A tab. One with no `to` of its own opens its first child, so it
|
||||
* must have one. */
|
||||
export type AdminNavItem = AdminNavBase &
|
||||
(
|
||||
| { to: string; children?: AdminNavLink[] }
|
||||
| { to?: undefined; children: [AdminNavLink, ...AdminNavLink[]] }
|
||||
);
|
||||
|
||||
export declare const CMS_NAV: AdminNavItem[];
|
||||
export declare const FORMS_NAV: AdminNavItem & { children: [AdminNavLink, ...AdminNavLink[]] };
|
||||
export declare const PANEL_NAV: AdminNavItem;
|
||||
export declare const NAV: AdminNavItem[];
|
||||
|
||||
export declare function navFor(user: AdminUser | null | undefined): AdminNavItem[];
|
||||
|
||||
export declare const matches: (pathname: string, to: string | null | undefined) => boolean;
|
||||
|
||||
export declare const target: (item: AdminNavItem) => string;
|
||||
|
||||
export declare function areaFor(pathname: string): AdminArea;
|
||||
|
|
@ -64,12 +64,6 @@ export const CMS_NAV = [
|
|||
label: "Timeline",
|
||||
blurb: "What the history page shows, and the order it shows it in.",
|
||||
},
|
||||
// One record, not a list — the tab opens it directly.
|
||||
{
|
||||
to: "/admin/front_page",
|
||||
label: "Front page",
|
||||
blurb: "The hero, its photos or livestream, and which bands the home page draws.",
|
||||
},
|
||||
];
|
||||
|
||||
/* Forms are submissions coming in rather than content going out, so
|
||||
|
|
|
|||
|
|
@ -1,791 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CALENDAR
|
||||
|
||||
Every published event on a month grid, or as a list of the month.
|
||||
Self contained like EventListCards: give it a filter and it
|
||||
fetches, so it can sit on any page.
|
||||
|
||||
<EventCalendar /> everything
|
||||
<EventCalendar host="northwest" /> one host's calendar
|
||||
<EventCalendar section="national" /> one scope
|
||||
<EventCalendar type={["class", "workshop"]} controls={["search"]} />
|
||||
|
||||
Two layers of filtering, and they answer different questions:
|
||||
|
||||
props what this page's calendar is about. Pinned; the
|
||||
visitor can't widen them, and the control for a
|
||||
pinned dimension doesn't render.
|
||||
controls what the visitor can narrow by within that: scope,
|
||||
type, online only, search. All four by default,
|
||||
every one starting at "all".
|
||||
|
||||
What lands on a day:
|
||||
|
||||
one-off every day from starts_on to ends_on, drawn as one
|
||||
bar across the days it spans, broken at the week
|
||||
edge and marked as continuing
|
||||
series every meeting the schedule produces in view (see
|
||||
eventSeries.ts), one day each, with its time
|
||||
undated nowhere — there's no day to put it on. Counted
|
||||
under the grid so it doesn't vanish without a word.
|
||||
|
||||
Bars are laid out per week in lanes, so a long event keeps its
|
||||
row across the days it covers. MAX_LANES rows show; a day with
|
||||
more says "+N more", and clicking any day lists everything on it
|
||||
below the grid.
|
||||
|
||||
Below md the grid would be seven unreadable slivers, so the month
|
||||
view shows the list there instead. The toggle still works; it
|
||||
just has one answer on a phone.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { typesPresent, useEvents } from '../../data/eventData.js'
|
||||
import type { EventFilter } from '../../data/eventData.js'
|
||||
import { EVENT_TYPES, eventTypeLabel } from '../../lib/eventTypes.ts'
|
||||
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
|
||||
import { eventHref } from '../../lib/hrefs.ts'
|
||||
import type { EventListItem } from '../../lib/useContent.ts'
|
||||
|
||||
export type CalendarControl = 'scope' | 'type' | 'online' | 'search'
|
||||
export type CalendarView = 'month' | 'list'
|
||||
|
||||
const ALL_CONTROLS: CalendarControl[] = ['scope', 'type', 'online', 'search']
|
||||
|
||||
/* Bar rows drawn per week before a day collapses to "+N more". */
|
||||
const MAX_LANES = 3
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const INK = '#073d4a'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
type EventCalendarProps = EventFilter & {
|
||||
accent?: string
|
||||
/** Which visitor controls render. A pinned prop hides its own. */
|
||||
controls?: CalendarControl[]
|
||||
defaultView?: CalendarView
|
||||
}
|
||||
|
||||
/* One appearance of an event on the calendar: a whole one-off, or a
|
||||
single meeting of a series. Dates are inclusive 'YYYY-MM-DD'. */
|
||||
type Occurrence = {
|
||||
key: string
|
||||
event: EventListItem
|
||||
start: string
|
||||
end: string
|
||||
time: string | null
|
||||
}
|
||||
|
||||
type Segment = Occurrence & {
|
||||
col: number
|
||||
span: number
|
||||
lane: number
|
||||
continuesBefore: boolean
|
||||
continuesAfter: boolean
|
||||
}
|
||||
|
||||
/* ── Dates ───────────────────────────────────────────────────── */
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
|
||||
function parse(date: string): Date {
|
||||
const [y, m, d] = date.split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
const addDays = (date: string, n: number) => {
|
||||
const d = parse(date)
|
||||
d.setDate(d.getDate() + n)
|
||||
return iso(d)
|
||||
}
|
||||
|
||||
const dayDiff = (a: string, b: string) =>
|
||||
Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000)
|
||||
|
||||
const monthKey = (date: string) => date.slice(0, 7)
|
||||
|
||||
/* The six Sunday-started weeks that cover a month. */
|
||||
function gridFor(month: string): string[][] {
|
||||
const first = parse(`${month}-01`)
|
||||
const start = addDays(iso(first), -first.getDay())
|
||||
return Array.from({ length: 6 }, (_, w) =>
|
||||
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
|
||||
)
|
||||
}
|
||||
|
||||
const monthTitle = (month: string) =>
|
||||
parse(`${month}-01`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
const shiftMonth = (month: string, n: number) => {
|
||||
const d = parse(`${month}-01`)
|
||||
d.setMonth(d.getMonth() + n)
|
||||
return monthKey(iso(d))
|
||||
}
|
||||
|
||||
const longDay = (date: string) =>
|
||||
parse(date).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
function rangeLabel(start: string, end: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
|
||||
if (start === end) return parse(start).toLocaleDateString(undefined, opts)
|
||||
return `${parse(start).toLocaleDateString(undefined, opts)} – ${parse(end).toLocaleDateString(undefined, opts)}`
|
||||
}
|
||||
|
||||
/* ── Occurrences ─────────────────────────────────────────────── */
|
||||
|
||||
function occurrencesIn(events: EventListItem[], from: string, to: string): Occurrence[] {
|
||||
const out: Occurrence[] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.starts_on) continue
|
||||
|
||||
if (event.series) {
|
||||
const time = seriesTimes(event.series)
|
||||
for (const date of occurrencesBetween(event.series, event.starts_on, event.ends_on, from, to)) {
|
||||
out.push({ key: `${event.id}@${date}`, event, start: date, end: date, time })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const start = event.starts_on
|
||||
const end = event.ends_on && event.ends_on >= start ? event.ends_on : start
|
||||
if (end < from || start > to) continue
|
||||
out.push({ key: event.id, event, start, end, time: null })
|
||||
}
|
||||
|
||||
// Longest first within a day, so multi-day bars claim the top lanes.
|
||||
return out.sort(
|
||||
(a, b) => a.start.localeCompare(b.start) || dayDiff(b.start, b.end) - dayDiff(a.start, a.end),
|
||||
)
|
||||
}
|
||||
|
||||
/* Greedy lane packing for one week: each segment takes the first
|
||||
lane whose last occupant ended before it starts. */
|
||||
function layoutWeek(week: string[], occurrences: Occurrence[]): Segment[] {
|
||||
const first = week[0]
|
||||
const last = week[6]
|
||||
const laneEnds: number[] = []
|
||||
const segments: Segment[] = []
|
||||
|
||||
for (const occ of occurrences) {
|
||||
if (occ.end < first || occ.start > last) continue
|
||||
const start = occ.start < first ? first : occ.start
|
||||
const end = occ.end > last ? last : occ.end
|
||||
const col = dayDiff(first, start)
|
||||
const span = dayDiff(start, end) + 1
|
||||
|
||||
let lane = laneEnds.findIndex((endCol) => endCol < col)
|
||||
if (lane === -1) lane = laneEnds.length
|
||||
laneEnds[lane] = col + span - 1
|
||||
|
||||
segments.push({
|
||||
...occ,
|
||||
col,
|
||||
span,
|
||||
lane,
|
||||
continuesBefore: occ.start < first,
|
||||
continuesAfter: occ.end > last,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/* The next date after `after` that any of these events lands on. */
|
||||
function nextDateAfter(events: EventListItem[], after: string): string | null {
|
||||
let best: string | null = null
|
||||
const from = addDays(after, 1)
|
||||
for (const event of events) {
|
||||
if (!event.starts_on) continue
|
||||
const date = event.series
|
||||
? firstOccurrenceFrom(event.series, event.starts_on, event.ends_on, from)
|
||||
: event.starts_on >= from
|
||||
? event.starts_on
|
||||
: null
|
||||
if (date && (!best || date < best)) best = date
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
|
||||
export default function EventCalendar({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
accent = TEAL,
|
||||
controls = ALL_CONTROLS,
|
||||
defaultView = 'month',
|
||||
}: EventCalendarProps) {
|
||||
const { events: pinned, sections, loading, error } = useEvents({ section, 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 [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 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 events = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
return pinned.filter((e) => {
|
||||
if (scope && e.section_id !== scope) return false
|
||||
if (kind && e.event_type !== kind) return false
|
||||
if (onlineOnly && !e.is_online) return false
|
||||
if (needle) {
|
||||
const haystack = [
|
||||
e.title,
|
||||
e.theme,
|
||||
e.location_label,
|
||||
e.locality,
|
||||
...(e.hosts ?? []).map((h) => h.name),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (!haystack.includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [pinned, scope, kind, onlineOnly, query])
|
||||
|
||||
const weeks = useMemo(() => gridFor(month), [month])
|
||||
const gridFrom = weeks[0][0]
|
||||
const gridTo = weeks[5][6]
|
||||
const monthFrom = `${month}-01`
|
||||
const monthTo = addDays(`${shiftMonth(month, 1)}-01`, -1)
|
||||
|
||||
const occurrences = useMemo(
|
||||
() => occurrencesIn(events, gridFrom, gridTo),
|
||||
[events, gridFrom, gridTo],
|
||||
)
|
||||
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 next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
|
||||
|
||||
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
|
||||
|
||||
const go = (target: string) => {
|
||||
setMonth(target)
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setScope('')
|
||||
setKind('')
|
||||
setOnlineOnly(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6" style={{ color: INK }}>
|
||||
{/* ── Month navigation and view ── */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavButton label="Previous month" onClick={() => go(shiftMonth(month, -1))} accent={accent}>
|
||||
‹
|
||||
</NavButton>
|
||||
<NavButton label="Next month" onClick={() => go(shiftMonth(month, 1))} accent={accent}>
|
||||
›
|
||||
</NavButton>
|
||||
</div>
|
||||
|
||||
<h3 className="min-w-[11rem] font-display text-2xl font-bold" aria-live="polite">
|
||||
{monthTitle(month)}
|
||||
</h3>
|
||||
|
||||
{month !== monthKey(today) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(monthKey(today))}
|
||||
className="rounded-full border px-3 py-1 text-sm font-semibold hover:bg-white"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ml-auto hidden overflow-hidden rounded-full border md:flex"
|
||||
style={{ borderColor: accent }}
|
||||
role="group"
|
||||
aria-label="Calendar view"
|
||||
>
|
||||
{(['month', 'list'] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setView(option)}
|
||||
aria-pressed={view === option}
|
||||
className="px-4 py-1.5 text-sm font-semibold capitalize transition-colors"
|
||||
style={
|
||||
view === option
|
||||
? { background: accent, color: '#ffffff' }
|
||||
: { color: accent }
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filters ── */}
|
||||
{(showScope || showType || show('online') || show('search')) && (
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
{showScope && scopes.length > 1 && (
|
||||
<FilterSelect
|
||||
label="Scope"
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
all="All scopes"
|
||||
options={scopes.map((s) => [s.id, s.name])}
|
||||
/>
|
||||
)}
|
||||
{showType && types.length > 1 && (
|
||||
<FilterSelect
|
||||
label="Type"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
all="All types"
|
||||
options={types.map((t) => [t.id, t.plural])}
|
||||
/>
|
||||
)}
|
||||
{show('online') && (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlineOnly}
|
||||
onChange={(e) => setOnlineOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ accentColor: accent }}
|
||||
/>
|
||||
Online only
|
||||
</label>
|
||||
)}
|
||||
{show('search') && (
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search events"
|
||||
aria-label="Search events"
|
||||
className="min-w-[12rem] flex-1 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0] md:max-w-xs"
|
||||
/>
|
||||
)}
|
||||
{filtering && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="text-sm font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="mt-6">
|
||||
{error ? (
|
||||
<p className="rounded-2xl bg-white p-6 text-[#b3261e]">
|
||||
Couldn’t load events. {error.message}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="h-[32rem] animate-pulse rounded-3xl bg-white/70" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{view === 'month' && (
|
||||
<div className="hidden md:block">
|
||||
<MonthGrid
|
||||
weeks={weeks}
|
||||
month={month}
|
||||
today={today}
|
||||
occurrences={occurrences}
|
||||
selected={selected}
|
||||
onSelect={(date) => setSelected((s) => (s === date ? null : date))}
|
||||
accent={accent}
|
||||
/>
|
||||
{selected && (
|
||||
<DayPanel
|
||||
date={selected}
|
||||
items={onDay(selected)}
|
||||
accent={accent}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={view === 'month' ? 'md:hidden' : ''}>
|
||||
<MonthList items={inMonth} monthFrom={monthFrom} accent={accent} />
|
||||
</div>
|
||||
|
||||
{inMonth.length === 0 && (
|
||||
<div className="mt-4 text-center text-sm" style={{ color: BODY }}>
|
||||
{next ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(monthKey(next))}
|
||||
className="font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Jump to the next event, {monthTitle(monthKey(next))} →
|
||||
</button>
|
||||
) : filtering ? (
|
||||
'Nothing matches these filters.'
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{undated > 0 && (
|
||||
<p className="mt-4 text-center text-xs" style={{ color: BODY }}>
|
||||
{undated === 1 ? '1 event has' : `${undated} events have`} no dates yet, so{' '}
|
||||
{undated === 1 ? 'isn’t' : 'aren’t'} on the calendar.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Month grid ──────────────────────────────────────────────── */
|
||||
|
||||
type MonthGridProps = {
|
||||
weeks: string[][]
|
||||
month: string
|
||||
today: string
|
||||
occurrences: Occurrence[]
|
||||
selected: string | null
|
||||
onSelect: (date: string) => void
|
||||
accent: string
|
||||
}
|
||||
|
||||
function MonthGrid({ weeks, month, today, occurrences, selected, onSelect, accent }: MonthGridProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-[#138ba0]/15 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-7 border-b border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="px-3 py-2 text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: BODY }}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{weeks.map((week) => {
|
||||
const segments = layoutWeek(week, occurrences)
|
||||
const visible = segments.filter((s) => s.lane < MAX_LANES)
|
||||
const hiddenOn = (col: number) =>
|
||||
segments.filter((s) => s.lane >= MAX_LANES && s.col <= col && s.col + s.span > col).length
|
||||
const countOn = (col: number) =>
|
||||
segments.filter((s) => s.col <= col && s.col + s.span > col).length
|
||||
|
||||
return (
|
||||
<div key={week[0]} className="relative min-h-[8rem] border-b border-[#138ba0]/10 last:border-b-0">
|
||||
{/* Day cells: the click targets, and the numbers. */}
|
||||
<div className="absolute inset-0 grid grid-cols-7">
|
||||
{week.map((date, col) => {
|
||||
const outside = monthKey(date) !== month
|
||||
const isToday = date === today
|
||||
const isSelected = date === selected
|
||||
const count = countOn(col)
|
||||
const hidden = hiddenOn(col)
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
onClick={() => onSelect(date)}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${longDay(date)}${count ? `, ${count} event${count === 1 ? '' : 's'}` : ''}`}
|
||||
className="relative flex flex-col items-start border-r border-[#138ba0]/10 p-2 text-left transition-colors last:border-r-0 hover:bg-[#f6fbfc]"
|
||||
style={{
|
||||
background: isSelected ? `${accent}14` : outside ? '#fbfdfd' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: '#ffffff' }
|
||||
: { color: outside ? '#b8c6c9' : INK }
|
||||
}
|
||||
>
|
||||
{Number(date.slice(8))}
|
||||
</span>
|
||||
{hidden > 0 && (
|
||||
<span className="mt-auto text-xs font-semibold" style={{ color: accent }}>
|
||||
+{hidden} more
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bars, laid over the cells. Only the bars take clicks. */}
|
||||
<div
|
||||
className="pointer-events-none relative grid grid-cols-7 gap-y-1 pb-7 pt-10"
|
||||
style={{ gridTemplateRows: `repeat(${MAX_LANES}, 1.5rem)` }}
|
||||
>
|
||||
{visible.map((seg) => (
|
||||
<Bar key={`${seg.key}-${week[0]}`} seg={seg} accent={accent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bar({ seg, accent }: { seg: Segment; accent: string }) {
|
||||
const color = seg.event.color || accent
|
||||
const cancelled = seg.event.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={eventHref(seg.event.id)}
|
||||
title={`${seg.event.title}${seg.time ? ` · ${seg.time}` : ''}`}
|
||||
className={`pointer-events-auto flex items-center gap-1 truncate px-2 text-xs font-semibold text-white transition-[filter] hover:brightness-110 ${
|
||||
seg.continuesBefore ? 'ml-0 rounded-l-none' : 'ml-1 rounded-l-md'
|
||||
} ${seg.continuesAfter ? 'mr-0 rounded-r-none' : 'mr-1 rounded-r-md'} ${
|
||||
cancelled ? 'line-through opacity-60' : ''
|
||||
}`}
|
||||
style={{
|
||||
gridColumn: `${seg.col + 1} / span ${seg.span}`,
|
||||
gridRow: seg.lane + 1,
|
||||
background: color,
|
||||
}}
|
||||
>
|
||||
{seg.continuesBefore && <span aria-hidden="true">←</span>}
|
||||
<span className="truncate">
|
||||
{seg.time && seg.span === 1 && <span className="font-normal opacity-85">{seg.time.split(' – ')[0]} </span>}
|
||||
{seg.event.title}
|
||||
</span>
|
||||
{seg.continuesAfter && (
|
||||
<span className="ml-auto" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The selected day ────────────────────────────────────────── */
|
||||
|
||||
function DayPanel({
|
||||
date,
|
||||
items,
|
||||
accent,
|
||||
onClose,
|
||||
}: {
|
||||
date: string
|
||||
items: Occurrence[]
|
||||
accent: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 rounded-3xl border bg-white p-6" style={{ borderColor: `${accent}40` }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h4 className="font-display text-lg font-bold">{longDay(date)}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto text-sm font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm" style={{ color: BODY }}>
|
||||
Nothing on this day.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
<EventLine item={item} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── List view ───────────────────────────────────────────────── */
|
||||
|
||||
/* The month's occurrences by day. An event that began last month
|
||||
files under the 1st, where it's still on. */
|
||||
function MonthList({
|
||||
items,
|
||||
monthFrom,
|
||||
accent,
|
||||
}: {
|
||||
items: Occurrence[]
|
||||
monthFrom: string
|
||||
accent: string
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="rounded-3xl bg-white p-10 text-center" style={{ color: BODY }}>
|
||||
Nothing on the calendar this month.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const byDay = new Map<string, Occurrence[]>()
|
||||
for (const item of items) {
|
||||
const day = item.start < monthFrom ? monthFrom : item.start
|
||||
const list = byDay.get(day)
|
||||
if (list) list.push(item)
|
||||
else byDay.set(day, [item])
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-4">
|
||||
{[...byDay.entries()].map(([day, list]) => {
|
||||
const d = parse(day)
|
||||
return (
|
||||
<li key={day} className="flex gap-5 rounded-3xl bg-white p-5 shadow-sm">
|
||||
<div className="w-14 shrink-0 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: accent }}>
|
||||
{d.toLocaleDateString(undefined, { weekday: 'short' })}
|
||||
</p>
|
||||
<p className="font-display text-3xl font-extrabold leading-none">{d.getDate()}</p>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{list.map((item) => (
|
||||
<li key={item.key}>
|
||||
<EventLine item={item} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/* One occurrence as a line of text, shared by the day panel and the
|
||||
list so the two describe an event the same way. */
|
||||
function EventLine({ item, accent }: { item: Occurrence; accent: string }) {
|
||||
const { event } = item
|
||||
const color = event.color || accent
|
||||
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||
const cancelled = event.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={eventHref(event.id)}
|
||||
className="group flex items-start gap-3 rounded-xl p-2 transition-colors hover:bg-[#f6fbfc]"
|
||||
>
|
||||
<span className="mt-1.5 h-3 w-3 shrink-0 rounded-full" style={{ background: color }} aria-hidden="true" />
|
||||
<span className="min-w-0">
|
||||
<span className={`block font-semibold group-hover:underline ${cancelled ? 'line-through' : ''}`}>
|
||||
{event.title}
|
||||
{cancelled && <span className="ml-2 text-xs font-bold uppercase text-[#b3261e] no-underline">Cancelled</span>}
|
||||
</span>
|
||||
<span className="block text-sm" style={{ color: BODY }}>
|
||||
{[
|
||||
item.time ?? (item.start !== item.end ? rangeLabel(item.start, item.end) : null),
|
||||
eventTypeLabel(event.event_type),
|
||||
where,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Controls ────────────────────────────────────────────────── */
|
||||
|
||||
function NavButton({
|
||||
label,
|
||||
onClick,
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
accent: string
|
||||
children: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border text-xl transition-colors hover:bg-white"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
all,
|
||||
options,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
all: string
|
||||
options: Array<[string, string]>
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
className="rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0]"
|
||||
>
|
||||
<option value="">{all}</option>
|
||||
{options.map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
splitByStatus,
|
||||
typesPresent,
|
||||
useEvents,
|
||||
type EventFilter,
|
||||
} from "../../data/eventData.js";
|
||||
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||
import { eventHref } from "../../lib/hrefs.ts";
|
||||
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
|
||||
import { seriesLabel } from "../../lib/eventSeries.ts";
|
||||
import type { EventListItem } from "../../lib/useContent.ts";
|
||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
|
@ -32,7 +24,7 @@ import type { SectionToggleProps } from "../../lib/sections.tsx";
|
|||
nothing but retreats shows no control at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
|
@ -41,7 +33,7 @@ const LOGOS = Object.fromEntries(
|
|||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
|
||||
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
|
||||
/* Last resort only. The API already falls back to the host
|
||||
organization's logo when an event doesn't name its own, so this
|
||||
|
|
@ -49,15 +41,7 @@ const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || nu
|
|||
const DEFAULT_ORG_LOGO = null;
|
||||
|
||||
/* An <img> that removes itself if the file 404s. */
|
||||
function Logo({
|
||||
file,
|
||||
alt = "",
|
||||
className,
|
||||
}: {
|
||||
file: string | null | undefined;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
function Logo({ file, alt = "", className }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = logoSrc(file);
|
||||
if (!src || failed) return null;
|
||||
|
|
@ -138,15 +122,7 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
carousel has the same constraint — it needs the card's click to
|
||||
mean "bring this one to the front" on anything that isn't the
|
||||
active slide. */
|
||||
function TitleLink({
|
||||
ev,
|
||||
linked,
|
||||
children,
|
||||
}: {
|
||||
ev: Pick<EventListItem, "id">;
|
||||
linked: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function TitleLink({ ev, linked, children }) {
|
||||
if (!linked) return <>{children}</>;
|
||||
return (
|
||||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||
|
|
@ -173,16 +149,6 @@ function TitleLink({
|
|||
`linked` is the one thing a caller turns off: a card on the
|
||||
event's own page shouldn't link to the page it's already on.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
type CardProps = {
|
||||
ev: EventListItem;
|
||||
defaultColor?: string;
|
||||
accent?: string;
|
||||
compact?: boolean;
|
||||
interactive?: boolean;
|
||||
linked?: boolean;
|
||||
showType?: boolean;
|
||||
};
|
||||
|
||||
export function Card({
|
||||
ev,
|
||||
defaultColor = TEAL,
|
||||
|
|
@ -191,17 +157,16 @@ export function Card({
|
|||
interactive = true,
|
||||
linked = true,
|
||||
showType = false,
|
||||
}: CardProps) {
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||
const eventLogo = ev.event_logo;
|
||||
const schedule = seriesLabel(ev.series, ev.starts_on);
|
||||
const links = ev.links ?? [];
|
||||
const igHandle = ev.instagram || null;
|
||||
const igUrl = igHandle
|
||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: undefined;
|
||||
: null;
|
||||
|
||||
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||||
on every card in a row of nothing but retreats is noise, and
|
||||
|
|
@ -257,7 +222,6 @@ export function Card({
|
|||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||||
{schedule && <p className="text-xl">{schedule}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-xl">{ev.location_label}</p>
|
||||
)}
|
||||
|
|
@ -285,7 +249,6 @@ export function Card({
|
|||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||||
{schedule && <p className="text-2xl">{schedule}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-2xl">{ev.location_label}</p>
|
||||
)}
|
||||
|
|
@ -390,23 +353,14 @@ export function Card({
|
|||
them visible is one tap, and the row reads as what the section
|
||||
contains rather than as a form control.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
type TypeFilterValue = EventType | "all";
|
||||
|
||||
type TypeFilterProps = {
|
||||
types: typeof EVENT_TYPES;
|
||||
active: TypeFilterValue;
|
||||
setActive: (id: TypeFilterValue) => void;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
|
||||
const chip = (on: boolean) => ({
|
||||
export function TypeFilter({ types, active, setActive, accent }) {
|
||||
const chip = on => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: on ? accent : "transparent",
|
||||
color: on ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
const button = (id: TypeFilterValue, label: string) => (
|
||||
const button = (id, label) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActive(id)}
|
||||
|
|
@ -434,12 +388,8 @@ export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({
|
||||
view,
|
||||
setView,
|
||||
accent,
|
||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
||||
const btn = (active: boolean) => ({
|
||||
export function EventCardsToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
|
@ -491,14 +441,6 @@ export function EventCardsToggle({
|
|||
differently to someone waiting, so they're distinguished rather
|
||||
than all falling through to "coming soon".
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
type EventListCardsProps = EventFilter & {
|
||||
/** "carousel" or "grid". */
|
||||
view?: string;
|
||||
accent?: string;
|
||||
defaultColor?: string;
|
||||
empty?: string;
|
||||
};
|
||||
|
||||
export default function EventListCards({
|
||||
section,
|
||||
host,
|
||||
|
|
@ -508,7 +450,7 @@ export default function EventListCards({
|
|||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}: EventListCardsProps) {
|
||||
}) {
|
||||
const { events: fetched, loading, error } = useEvents({
|
||||
section,
|
||||
host,
|
||||
|
|
@ -519,7 +461,7 @@ export default function EventListCards({
|
|||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
|
||||
const [activeType, setActiveType] = useState("all");
|
||||
|
||||
/* What this band holds, which is what the chips offer — not the
|
||||
full list of declared types, three quarters of which would be
|
||||
|
|
@ -573,7 +515,7 @@ export default function EventListCards({
|
|||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||
|
||||
// Arrows and dots follow the section accent, not the active card.
|
||||
const arrowStyle = (enabled: boolean) => ({
|
||||
const arrowStyle = enabled => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: enabled ? accent : "#b8c6c9",
|
||||
|
|
@ -581,7 +523,7 @@ export default function EventListCards({
|
|||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
const notice = (text: string) => (
|
||||
const notice = text => (
|
||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||
{text}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
page to the nav adds it to this form too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
||||
|
|
@ -46,7 +46,7 @@ function OptionalTag() {
|
|||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }: { id: string; children?: ReactNode }) {
|
||||
function FieldError({ id, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||
|
|
@ -56,15 +56,7 @@ function FieldError({ id, children }: { id: string; children?: ReactNode }) {
|
|||
}
|
||||
|
||||
// Native select plus a chevron, since appearance-none strips the default one.
|
||||
type SelectProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Select({ id, label, value, onChange, children }: SelectProps) {
|
||||
function Select({ id, label, value, onChange, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
|
|
@ -101,13 +93,7 @@ function Select({ id, label, value, onChange, children }: SelectProps) {
|
|||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
|
|
@ -165,16 +151,7 @@ function TypePicker({
|
|||
|
||||
/* ── Where on the site ───────────────────────────────────────── */
|
||||
|
||||
type LocationPickerProps = {
|
||||
/** A nav path, or SITE_WIDE. */
|
||||
page: string;
|
||||
/** A nav hash, or WHOLE_PAGE. */
|
||||
section: string;
|
||||
onPageChange: (page: string) => void;
|
||||
onSectionChange: (section: string) => void;
|
||||
};
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }: LocationPickerProps) {
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||
|
||||
return (
|
||||
|
|
@ -228,7 +205,7 @@ function LocationPicker({ page, section, onPageChange, onSectionChange }: Locati
|
|||
}
|
||||
|
||||
// Human-readable version of the picked location, for the thank-you panel.
|
||||
function describeLocation(page: string, section: string) {
|
||||
function describeLocation(page, section) {
|
||||
if (page === SITE_WIDE) return null;
|
||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||
|
|
@ -239,11 +216,8 @@ function describeLocation(page: string, section: string) {
|
|||
|
||||
/* ── The form ────────────────────────────────────────────────── */
|
||||
|
||||
/* The fields server/src/routes/feedback.js can reject by name. */
|
||||
type FeedbackFieldErrors = { message?: string; email?: string };
|
||||
|
||||
export default function FeedbackForm() {
|
||||
const [type, setType] = useState<string | null>(null);
|
||||
const [type, setType] = useState(null);
|
||||
const [page, setPage] = useState(SITE_WIDE);
|
||||
const [section, setSection] = useState(WHOLE_PAGE);
|
||||
const [message, setMessage] = useState("");
|
||||
|
|
@ -254,14 +228,14 @@ export default function FeedbackForm() {
|
|||
const [website, setWebsite] = useState("");
|
||||
|
||||
// idle → sending → sent, or back to idle with an error to show.
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<FeedbackFieldErrors>({});
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
|
||||
const sending = status === "sending";
|
||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!ready || sending) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import type { OrgKind } from "../../lib/hrefs.ts";
|
||||
import type { OrganizationListItem } from "../../lib/useContent.ts";
|
||||
import {
|
||||
initialsFor,
|
||||
orgPath,
|
||||
|
|
@ -35,13 +33,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
|||
const CARD_MIN = "20rem";
|
||||
const GRID_MAX = "88rem";
|
||||
|
||||
function OrgMark({
|
||||
org,
|
||||
color,
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
||||
color: string;
|
||||
}) {
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -66,14 +58,7 @@ function OrgMark({
|
|||
);
|
||||
}
|
||||
|
||||
type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
|
||||
|
||||
function OrgCard({
|
||||
org,
|
||||
accent,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: CardLabels & { org: OrganizationListItem }) {
|
||||
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||
const color = org.color || accent;
|
||||
const path = orgPath(org);
|
||||
|
||||
|
|
@ -151,13 +136,7 @@ function OrgCard({
|
|||
);
|
||||
}
|
||||
|
||||
function Block({
|
||||
title,
|
||||
orgs,
|
||||
accent,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
||||
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -188,30 +167,7 @@ function Block({
|
|||
);
|
||||
}
|
||||
|
||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
||||
a.name.localeCompare(b.name);
|
||||
|
||||
/* One heading's worth of the grid. Labels override the grid's own. */
|
||||
type OrgGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
};
|
||||
|
||||
type OrgListCardsProps = {
|
||||
kind?: OrgKind;
|
||||
title?: string;
|
||||
groups?: OrgGroup[];
|
||||
/** Which group key an organization files under. */
|
||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
||||
accent?: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
/** Null keeps the API's order. */
|
||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
||||
empty?: string;
|
||||
};
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListCards({
|
||||
kind,
|
||||
|
|
@ -223,10 +179,10 @@ export default function OrgListCards({
|
|||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}: OrgListCardsProps) {
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = (children: ReactNode) => (
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
useCommunity,
|
||||
type Chapter,
|
||||
type Community,
|
||||
type Region,
|
||||
} from "../../data/chapters.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommunity } from "../../data/chapters.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
||||
import { AREAS, AREA_NAMES, type AreaSlice } from "../../data/mapGrid.js";
|
||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||
import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts";
|
||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — MAP
|
||||
|
|
@ -53,19 +46,6 @@ const RULE = "#cfe3e7";
|
|||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* organizations.color is nullable; an SVG fill left undefined paints
|
||||
black, so an uncoloured region takes the same fallback as a card. */
|
||||
const colorOf = (item: { color?: string | null }) => item.color || FALLBACK_COLOR;
|
||||
|
||||
/* Which region is picked and which is under the pointer, shared by
|
||||
the map, the legend and the list. */
|
||||
type Highlight = {
|
||||
selected: string | null;
|
||||
hovered: string | null;
|
||||
setSelected: (id: string | null) => void;
|
||||
setHovered: (id: string | null) => void;
|
||||
};
|
||||
|
||||
const US_TITLE = "US Unity Regions";
|
||||
const INTL_TITLE = "International Unity Regions";
|
||||
|
||||
|
|
@ -75,7 +55,7 @@ const INTL_TITLE = "International Unity Regions";
|
|||
vanishing. When organization and person pages arrive this should
|
||||
move to a shared component; small enough to live here until then.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string }) {
|
||||
function Blocks({ blocks = [], color }) {
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -108,7 +88,7 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
|
|||
case "links":
|
||||
return (
|
||||
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
||||
{(block.items ?? []).map((item, j) => (
|
||||
{block.items.map((item, j) => (
|
||||
<li key={j}>
|
||||
{item.url ? (
|
||||
<a
|
||||
|
|
@ -154,19 +134,6 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
|
|||
subtracts, and the order the slices arrive in doesn't change
|
||||
what's drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
type TileProps = Highlight & {
|
||||
code: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
width?: number;
|
||||
label?: string;
|
||||
slices?: AreaSlice[];
|
||||
count?: number;
|
||||
onPick?: (code: string) => void;
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
function Tile({
|
||||
code,
|
||||
x,
|
||||
|
|
@ -182,12 +149,11 @@ function Tile({
|
|||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}: TileProps) {
|
||||
}) {
|
||||
if (slices.length === 0) return null;
|
||||
|
||||
const primary = slices[0];
|
||||
// Nullable so indexOf and includes take `selected` as it is.
|
||||
const ids: Array<string | null> = slices.map(s => s.regionId);
|
||||
const ids = slices.map(s => s.regionId);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
|
|
@ -230,7 +196,7 @@ function Tile({
|
|||
y={top}
|
||||
width={width}
|
||||
height={h}
|
||||
fill={colorOf(slice)}
|
||||
fill={slice.color}
|
||||
fillOpacity={opacity}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
|
@ -245,7 +211,7 @@ function Tile({
|
|||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? colorOf(primary) : "#ffffff"}
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
|
|
@ -258,7 +224,7 @@ function Tile({
|
|||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : colorOf(primary)}
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
|
|
@ -278,13 +244,7 @@ function Tile({
|
|||
);
|
||||
}
|
||||
|
||||
type RegionMapProps = Highlight & {
|
||||
slices: Record<string, AreaSlice[]>;
|
||||
chapterCounts: Record<string, number>;
|
||||
onPick?: (code: string) => void;
|
||||
};
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
|
||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||
const size = TILE - PAD * 2;
|
||||
|
||||
return (
|
||||
|
|
@ -315,15 +275,8 @@ function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function LegendButton({
|
||||
region,
|
||||
selected,
|
||||
setSelected,
|
||||
hovered,
|
||||
setHovered,
|
||||
}: Highlight & { region: Region }) {
|
||||
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === region.id || hovered === region.id;
|
||||
const color = colorOf(region);
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||
|
|
@ -334,36 +287,29 @@ function LegendButton({
|
|||
aria-pressed={selected === region.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: on ? color : "transparent",
|
||||
color: on ? "#ffffff" : color,
|
||||
border: `1px solid ${region.color}`,
|
||||
background: on ? region.color : "transparent",
|
||||
color: on ? "#ffffff" : region.color,
|
||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : color }}
|
||||
style={{ background: on ? "#ffffff" : region.color }}
|
||||
/>
|
||||
{region.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LegendProps = Highlight & {
|
||||
domestic: Region[];
|
||||
international: Region[];
|
||||
/** Regions that paint at least one tile. */
|
||||
onMapIds: Set<string>;
|
||||
};
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||
const { selected, setSelected } = props;
|
||||
|
||||
// A region is on the map if it paints a tile. West Central used
|
||||
// to need a hardcoded exception here because its states arrived
|
||||
// only through SPLITS; it has ordinary rows now, so the exception
|
||||
// is gone.
|
||||
const onMap = (region: Region) => onMapIds.has(region.id);
|
||||
const onMap = region => onMapIds.has(region.id);
|
||||
const us = domestic.filter(onMap);
|
||||
const intl = international.filter(onMap);
|
||||
|
||||
|
|
@ -401,15 +347,6 @@ function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
|||
);
|
||||
}
|
||||
|
||||
type RegionBlockProps = Highlight & {
|
||||
region: Region;
|
||||
chapters: Chapter[];
|
||||
subtext: string;
|
||||
indent: boolean;
|
||||
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
|
||||
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
|
||||
};
|
||||
|
||||
function RegionBlock({
|
||||
region,
|
||||
chapters,
|
||||
|
|
@ -420,15 +357,12 @@ function RegionBlock({
|
|||
indent,
|
||||
regionRefs,
|
||||
chapterRefs,
|
||||
}: RegionBlockProps) {
|
||||
}) {
|
||||
const on = selected === region.id;
|
||||
const color = colorOf(region);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => {
|
||||
if (regionRefs) regionRefs.current[region.id] = el;
|
||||
}}
|
||||
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
|
|
@ -440,11 +374,11 @@ function RegionBlock({
|
|||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: color }}
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: color }}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
{region.name}
|
||||
</h4>
|
||||
|
|
@ -465,16 +399,12 @@ function RegionBlock({
|
|||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => {
|
||||
const path = orgPath(c);
|
||||
return (
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => {
|
||||
if (chapterRefs) chapterRefs.current[c.id] = el;
|
||||
}}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3 flex items-start gap-3"
|
||||
style={{ borderLeft: `2px solid ${color}` }}
|
||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700">{c.name}</p>
|
||||
|
|
@ -490,7 +420,7 @@ function RegionBlock({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: color }}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
|
|
@ -499,7 +429,7 @@ function RegionBlock({
|
|||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: color }}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
|
|
@ -508,17 +438,16 @@ function RegionBlock({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{path && (
|
||||
{orgPath(c) && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
to={orgPath(c)}
|
||||
label={`${c.name} — chapter page`}
|
||||
color={color}
|
||||
color={region.color}
|
||||
size="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -533,15 +462,7 @@ function RegionBlock({
|
|||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Logo, or the organization's initials when there's no file. */
|
||||
function OrgLogo({
|
||||
org,
|
||||
color,
|
||||
size = "h-14 w-14",
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
||||
color: string;
|
||||
size?: string;
|
||||
}) {
|
||||
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -566,16 +487,7 @@ function OrgLogo({
|
|||
);
|
||||
}
|
||||
|
||||
type ChapterCardProps = {
|
||||
chapter: Chapter;
|
||||
color: string;
|
||||
open: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
||||
const path = orgPath(chapter);
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
|
|
@ -627,9 +539,9 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{path && (
|
||||
{orgPath(chapter) && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
to={orgPath(chapter)}
|
||||
label={`${chapter.name} — chapter page`}
|
||||
color={color}
|
||||
/>
|
||||
|
|
@ -639,18 +551,7 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({
|
||||
chapter,
|
||||
region,
|
||||
onClose,
|
||||
}: {
|
||||
chapter: Chapter;
|
||||
region: Region;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const path = orgPath(chapter);
|
||||
const color = colorOf(region);
|
||||
|
||||
function ChapterDetail({ chapter, region, onClose }) {
|
||||
/* "Led by" comes from affiliations rather than a text field, so
|
||||
it lists real people and stays empty until they exist. */
|
||||
const leads = (chapter.leadership ?? [])
|
||||
|
|
@ -659,23 +560,21 @@ function ChapterDetail({
|
|||
)
|
||||
.join(", ");
|
||||
|
||||
const rows = (
|
||||
[
|
||||
const rows = [
|
||||
["Region", region.name],
|
||||
["Where", chapter.venue],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", leads],
|
||||
["Since", chapter.started],
|
||||
] satisfies Array<[label: string, value: string | null | undefined]>
|
||||
).filter(([, v]) => v);
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${color}`, background: `${color}0f` }}
|
||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgLogo org={chapter} color={color} size="h-20 w-20" />
|
||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
|
|
@ -686,13 +585,13 @@ function ChapterDetail({
|
|||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Blocks blocks={chapter.blocks} color={color} />
|
||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
|
|
@ -710,11 +609,11 @@ function ChapterDetail({
|
|||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{path && (
|
||||
{orgPath(chapter) && (
|
||||
<Link
|
||||
to={path}
|
||||
to={orgPath(chapter)}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ background: color, color: "#ffffff" }}
|
||||
style={{ background: region.color, color: "#ffffff" }}
|
||||
>
|
||||
Chapter page
|
||||
</Link>
|
||||
|
|
@ -725,7 +624,7 @@ function ChapterDetail({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Visit site
|
||||
</a>
|
||||
|
|
@ -735,7 +634,7 @@ function ChapterDetail({
|
|||
<a
|
||||
href={`mailto:${chapter.email}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
|
|
@ -745,19 +644,7 @@ function ChapterDetail({
|
|||
);
|
||||
}
|
||||
|
||||
type ChapterGridProps = Pick<Community, "regions" | "chapters" | "chaptersIn" | "subtextFor"> & {
|
||||
openId: string | null;
|
||||
setOpenId: (id: string | null) => void;
|
||||
};
|
||||
|
||||
function ChapterGrid({
|
||||
regions,
|
||||
chapters,
|
||||
chaptersIn,
|
||||
subtextFor,
|
||||
openId,
|
||||
setOpenId,
|
||||
}: ChapterGridProps) {
|
||||
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = regions
|
||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||
|
|
@ -769,15 +656,14 @@ function ChapterGrid({
|
|||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ region, list }) => {
|
||||
const subtext = subtextFor(region);
|
||||
const color = colorOf(region);
|
||||
return (
|
||||
<section key={region.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: color }}
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: color }}>
|
||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</h3>
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
|
|
@ -810,7 +696,7 @@ function ChapterGrid({
|
|||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={color}
|
||||
color={region.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
|
|
@ -831,12 +717,8 @@ function ChapterGrid({
|
|||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function OrgMapToggle({
|
||||
view,
|
||||
setView,
|
||||
accent,
|
||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
||||
const btn = (active: boolean) => ({
|
||||
export function OrgMapToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
|
@ -866,14 +748,7 @@ export function OrgMapToggle({
|
|||
);
|
||||
}
|
||||
|
||||
export default function OrgListMap({
|
||||
view = "map",
|
||||
accent = FALLBACK_COLOR,
|
||||
}: {
|
||||
/** "map" or "grid". */
|
||||
view?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
|
|
@ -890,16 +765,16 @@ export default function OrgListMap({
|
|||
subtextFor,
|
||||
} = useCommunity();
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null); // no card open on arrival
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||
|
||||
// The list scrolls itself to whatever the map or legend points at.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||
const listRef = useRef(null);
|
||||
const regionRefs = useRef({});
|
||||
const chapterRefs = useRef({});
|
||||
|
||||
const scrollListTo = (el: HTMLElement | null | undefined) => {
|
||||
const scrollListTo = el => {
|
||||
const box = listRef.current;
|
||||
if (!box || !el) return;
|
||||
// Only when the list is its own scroll area (lg and up). Below
|
||||
|
|
@ -917,7 +792,7 @@ export default function OrgListMap({
|
|||
|
||||
// Clicking a tile jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickArea = (code: string) => {
|
||||
const pickArea = code => {
|
||||
const chapter = chapters.find(c => c.area_code === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
|
|
@ -926,7 +801,7 @@ export default function OrgListMap({
|
|||
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||
};
|
||||
|
||||
const shell = (children: ReactNode) => (
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -962,7 +837,7 @@ export default function OrgListMap({
|
|||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||
|
||||
const block = (region: Region, indent: boolean) => (
|
||||
const block = (region, indent) => (
|
||||
<RegionBlock
|
||||
key={region.id}
|
||||
region={region}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import {
|
||||
useId,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useId, useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import type { OrgKind } from "../../lib/hrefs.ts";
|
||||
import type { OrganizationListItem } from "../../lib/useContent.ts";
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
|
|
@ -55,14 +47,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
|||
it — a link nested in a button is invalid, and a screen reader
|
||||
announces the whole row as one confused control.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
type RowButtonProps = {
|
||||
as?: "a" | "button";
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
} & AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
|
||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||
return (
|
||||
<As
|
||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
|
|
@ -74,13 +59,7 @@ function RowButton({ as: As = "button", color, children, ...rest }: RowButtonPro
|
|||
);
|
||||
}
|
||||
|
||||
function OrgMark({
|
||||
org,
|
||||
color,
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
|
||||
color: string;
|
||||
}) {
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -115,9 +94,7 @@ function OrgMark({
|
|||
);
|
||||
}
|
||||
|
||||
type RowLabels = { pageLabel: string; siteLabel: string };
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
|
||||
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
|
|
@ -268,12 +245,7 @@ function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationLi
|
|||
);
|
||||
}
|
||||
|
||||
function Block({
|
||||
title,
|
||||
orgs,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
||||
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -290,30 +262,7 @@ function Block({
|
|||
);
|
||||
}
|
||||
|
||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
||||
a.name.localeCompare(b.name);
|
||||
|
||||
/* One heading's worth of the list. Labels override the list's own. */
|
||||
type OrgGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
};
|
||||
|
||||
type OrgListVerticalProps = {
|
||||
kind?: OrgKind;
|
||||
title?: string;
|
||||
groups?: OrgGroup[];
|
||||
/** Which group key an organization files under. */
|
||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
||||
accent?: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
/** Null keeps the API's order. */
|
||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
||||
empty?: string;
|
||||
};
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListVertical({
|
||||
kind,
|
||||
|
|
@ -325,10 +274,10 @@ export default function OrgListVertical({
|
|||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}: OrgListVerticalProps) {
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = (children: ReactNode) => (
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
CALENDAR BAND
|
||||
|
||||
EventCalendar on the front page: every scope and every type, all
|
||||
four visitor filters, starting on this month. The same component
|
||||
can go on any page with a narrower filter — a region's page would
|
||||
pass host, a classes page would pass type — and this file only
|
||||
adds the front page's heading around it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import EventCalendar from '../EventCalendar.tsx'
|
||||
|
||||
type CalendarBandProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function CalendarBand({ id, title, blurb }: CalendarBandProps) {
|
||||
return (
|
||||
<section id={id} className="py-24" style={{ background: '#f6fbfc' }}>
|
||||
<div className="mx-auto mb-10 max-w-6xl px-6">
|
||||
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
|
||||
<EventCalendar />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FEATURED TIMELINE RAIL
|
||||
|
||||
The history page's featured entries, sideways: oldest on the
|
||||
left, so scrolling right moves forward through time. A line runs
|
||||
under the cards with a dot per entry, and a year label wherever
|
||||
the year changes. The last card goes to the full history.
|
||||
|
||||
Featured is the admin's call — timeline_entries.is_featured,
|
||||
"shown large" on the history page. Nothing else is filtered
|
||||
here; /history has already decided what's public.
|
||||
|
||||
Scrolling is native — touch, trackpad, shift-wheel — with
|
||||
scroll-snap so it settles on a card. A mouse can also drag it, and
|
||||
the arrow buttons step one card. A drag that moved more than a few
|
||||
pixels swallows the click it ends in, so letting go over a card
|
||||
doesn't open it.
|
||||
|
||||
Nothing featured, and the section doesn't render at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import HomeLink from './HomeLink.tsx'
|
||||
import { useHistory } from '../../../lib/useHistory.ts'
|
||||
import { hrefFor, logoSrc } from '../../../lib/timelineRefs.ts'
|
||||
import { MONTH_LABELS, type TimelineItem } from '../../../lib/timeline.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const DRAG_SLOP = 6
|
||||
|
||||
type RailProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function FeaturedTimelineRail({ id, title, blurb }: RailProps) {
|
||||
const { items, loading, error, reload } = useHistory()
|
||||
const featured = items
|
||||
.filter((item) => item.featured)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const railRef = useRef<HTMLOListElement>(null)
|
||||
const drag = useRef({ x: 0, left: 0, moved: false, active: false })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
if (!loading && !error && featured.length === 0) return null
|
||||
|
||||
const step = (direction: number) => {
|
||||
const rail = railRef.current
|
||||
const card = rail?.querySelector('li')
|
||||
if (!rail || !card) return
|
||||
rail.scrollBy({ left: direction * (card.clientWidth + 24), behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const onPointerDown = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||
if (e.pointerType !== 'mouse' || !railRef.current) return
|
||||
drag.current = { x: e.clientX, left: railRef.current.scrollLeft, moved: false, active: true }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||
const d = drag.current
|
||||
if (!d.active || !railRef.current) return
|
||||
const dx = e.clientX - d.x
|
||||
if (!d.moved && Math.abs(dx) > DRAG_SLOP) {
|
||||
d.moved = true
|
||||
setDragging(true)
|
||||
railRef.current.setPointerCapture(e.pointerId)
|
||||
}
|
||||
if (d.moved) railRef.current.scrollLeft = d.left - dx
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
drag.current.active = false
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="overflow-hidden py-24" style={{ background: '#f4faf7' }}>
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||
<div>
|
||||
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
|
||||
{featured.length > 1 && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{[
|
||||
[-1, '‹', 'Earlier'],
|
||||
[1, '›', 'Later'],
|
||||
].map(([direction, glyph, label]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => step(direction as number)}
|
||||
aria-label={label as string}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full border text-2xl transition-colors hover:bg-white"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mx-auto mt-10 max-w-6xl px-6 text-[#b3261e]">
|
||||
Couldn’t load the timeline. {error}{' '}
|
||||
<button type="button" onClick={reload} className="underline">
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="mx-auto mt-12 flex max-w-6xl gap-6 px-6" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-72 w-80 shrink-0 animate-pulse rounded-3xl bg-white" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ol
|
||||
ref={railRef}
|
||||
className={`hp-rail mt-12 flex gap-6 overflow-x-auto px-6 pb-4 md:px-[max(1.5rem,calc((100vw-72rem)/2+1.5rem))] ${
|
||||
dragging ? 'hp-rail--dragging' : ''
|
||||
}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
// Links and images are natively draggable, and a native
|
||||
// drag cancels the pointer stream this relies on.
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onClickCapture={(e) => {
|
||||
if (drag.current.moved) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
drag.current.moved = false
|
||||
}
|
||||
}}
|
||||
>
|
||||
{featured.map((item, index) => (
|
||||
<li key={item.id} className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||
<RailCard
|
||||
item={item}
|
||||
showYear={index === 0 || year(item) !== year(featured[index - 1])}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-12" />
|
||||
<Link
|
||||
to="/history"
|
||||
draggable={false}
|
||||
className="flex flex-1 flex-col justify-center rounded-3xl p-8 text-white transition-transform duration-300 hover:-translate-y-1"
|
||||
style={{ background: `linear-gradient(150deg, ${TEAL}, #073d4a)` }}
|
||||
>
|
||||
<span className="font-display text-2xl font-extrabold">The whole story</span>
|
||||
<span className="mt-2 text-white/75">Every year, every milestone.</span>
|
||||
<span className="mt-6 font-semibold">See the full history →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const year = (item: TimelineItem) => item.date.slice(0, 4)
|
||||
|
||||
/* "June 2014", "2014", "12 June 2014" — only as much as precision
|
||||
says is true. */
|
||||
function dateLabel(item: TimelineItem): string {
|
||||
const [y, m, d] = item.date.split('-')
|
||||
const month = m ? MONTH_LABELS[Number(m) - 1] : null
|
||||
if (item.precision === 'day' && d && month) return `${Number(d)} ${month} ${y}`
|
||||
if (item.precision !== 'year' && month) return `${month} ${y}`
|
||||
return y
|
||||
}
|
||||
|
||||
function RailCard({ item, showYear }: { item: TimelineItem; showYear: boolean }) {
|
||||
const href = hrefFor(item)
|
||||
const logo = logoSrc(item)
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
className="mb-5 h-14 w-14 object-contain"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em]" style={{ color: TEAL }}>
|
||||
{dateLabel(item)}
|
||||
</p>
|
||||
<p className="mt-2 font-display text-xl font-bold leading-snug text-[#073d4a]">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.meta && <p className="mt-1 text-sm text-[#4a6b72]">{item.meta}</p>}
|
||||
{item.blurb && (
|
||||
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||
)}
|
||||
{href && (
|
||||
<span className="mt-auto pt-5 text-sm font-semibold" style={{ color: TEAL }}>
|
||||
Read more →
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const card =
|
||||
'flex flex-1 flex-col rounded-3xl border border-[#138ba0]/15 bg-white p-7 shadow-sm'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* The line, its dot, and the year where it changes. */}
|
||||
<div className="relative mb-4 h-8">
|
||||
<div className="absolute inset-x-[-1.5rem] top-1/2 h-px bg-[#138ba0]/30" />
|
||||
<span
|
||||
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 bg-white"
|
||||
style={{ borderColor: TEAL }}
|
||||
/>
|
||||
{showYear && (
|
||||
<span
|
||||
className="absolute left-5 top-1/2 -translate-y-1/2 rounded-full px-3 py-0.5 font-display text-sm font-bold text-white"
|
||||
style={{ background: TEAL }}
|
||||
>
|
||||
{year(item)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{href ? (
|
||||
<HomeLink
|
||||
url={href}
|
||||
className={`${card} transition-all duration-300 hover:-translate-y-1 hover:shadow-xl`}
|
||||
>
|
||||
{body}
|
||||
</HomeLink>
|
||||
) : (
|
||||
<div className={card}>{body}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
HERO STAGE
|
||||
|
||||
The top of the front page, in whichever mode the admin set:
|
||||
|
||||
brand drifting colour and slow concentric rings — many
|
||||
circles, one centre — behind the words, with the
|
||||
dove at that centre
|
||||
photos the hero photos, crossfading with a slow zoom, a
|
||||
progress bar per photo and a pause button (anything
|
||||
that moves on its own for more than five seconds
|
||||
needs one)
|
||||
livestream the stream beside the words, with a LIVE badge
|
||||
|
||||
A mode that has nothing to show falls back to brand: photos with
|
||||
no photos, or a livestream link that can't be embedded. A stream
|
||||
link that can't be framed still gets a "Watch live" button, so
|
||||
switching the mode on is never a no-op.
|
||||
|
||||
`hero` is null while the page config loads or when it failed; the
|
||||
stage still draws, empty, so the page doesn't jump when it
|
||||
arrives. The error itself is shown by Home, not here.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
|
||||
import HomeLink from './HomeLink.tsx'
|
||||
import DoveMark from '../../../components/DoveMark.tsx'
|
||||
import { livestreamEmbedUrl } from '../../../lib/embeds.ts'
|
||||
import { heroPhoto } from '../../../lib/media.ts'
|
||||
import type { Hero, HeroSlide } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
const DEEP = '#04262e'
|
||||
|
||||
export default function HeroStage({ hero }: { hero: Hero | null }) {
|
||||
const slides = (hero?.slides ?? []).filter((slide) => heroPhoto(slide.media))
|
||||
const embed = livestreamEmbedUrl(hero?.livestream?.url)
|
||||
|
||||
const mode =
|
||||
hero?.mode === 'photos' && slides.length > 0
|
||||
? 'photos'
|
||||
: hero?.mode === 'livestream' && hero.livestream
|
||||
? 'livestream'
|
||||
: 'brand'
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative isolate flex min-h-[92vh] items-center overflow-hidden pt-24 pb-16"
|
||||
style={{ background: DEEP }}
|
||||
>
|
||||
{mode === 'photos' ? (
|
||||
<PhotoBackdrop slides={slides} seconds={hero?.slide_seconds ?? 7} />
|
||||
) : (
|
||||
<BrandBackdrop />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mx-auto grid w-full max-w-7xl items-center gap-12 px-6 lg:grid-cols-12">
|
||||
<div className={mode === 'livestream' ? 'lg:col-span-5' : 'lg:col-span-8'}>
|
||||
{hero && <Words hero={hero} live={mode === 'livestream'} />}
|
||||
</div>
|
||||
|
||||
{mode === 'livestream' && hero?.livestream && (
|
||||
<div className="lg:col-span-7">
|
||||
<LiveFrame src={embed} url={hero.livestream.url} title={hero.livestream.title} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The words ───────────────────────────────────────────────── */
|
||||
|
||||
function Words({ hero, live }: { hero: Hero; live: boolean }) {
|
||||
const words = hero.headline.split(/\s+/).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="text-white">
|
||||
{live ? (
|
||||
<p className="mb-6 inline-flex items-center gap-3 rounded-full bg-white/10 px-4 py-1.5 text-sm font-semibold uppercase tracking-[0.2em] backdrop-blur">
|
||||
<span className="hp-live-dot h-2.5 w-2.5 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||
Live now
|
||||
{hero.livestream?.title && (
|
||||
<span className="normal-case tracking-normal text-white/75">
|
||||
· {hero.livestream.title}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
hero.eyebrow && (
|
||||
<p className="mb-6 inline-block rounded-full border border-white/20 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.25em] text-[#9fe7d0]">
|
||||
{hero.eyebrow}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
<h1
|
||||
className={`font-display font-extrabold leading-[0.95] tracking-tight ${
|
||||
live ? 'text-5xl md:text-6xl' : 'text-6xl md:text-8xl'
|
||||
}`}
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<span key={`${word}-${index}`}>
|
||||
<span
|
||||
className="hp-rise"
|
||||
style={{ animationDelay: `${120 + index * 110}ms` }}
|
||||
>
|
||||
{word}
|
||||
</span>{' '}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
{hero.subhead && (
|
||||
<p
|
||||
className="hp-rise mt-8 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl"
|
||||
style={{ animationDelay: `${200 + words.length * 110}ms` }}
|
||||
>
|
||||
{hero.subhead}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(hero.primary || hero.secondary) && (
|
||||
<div
|
||||
className="hp-rise mt-10 flex flex-wrap gap-4"
|
||||
style={{ animationDelay: `${320 + words.length * 110}ms` }}
|
||||
>
|
||||
{hero.primary && (
|
||||
<HomeLink
|
||||
url={hero.primary.url}
|
||||
className="rounded-full px-8 py-4 text-lg font-bold text-[#04262e] shadow-xl transition-transform duration-300 hover:-translate-y-0.5 hover:scale-[1.03]"
|
||||
style={{ background: 'linear-gradient(120deg, #9fe7d0, #5ce7ff)' }}
|
||||
>
|
||||
{hero.primary.label}
|
||||
</HomeLink>
|
||||
)}
|
||||
{hero.secondary && (
|
||||
<HomeLink
|
||||
url={hero.secondary.url}
|
||||
className="rounded-full border border-white/35 px-8 py-4 text-lg font-semibold text-white transition-colors duration-300 hover:bg-white/10"
|
||||
>
|
||||
{hero.secondary.label} →
|
||||
</HomeLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Brand backdrop ──────────────────────────────────────────── */
|
||||
|
||||
function BrandBackdrop() {
|
||||
return (
|
||||
<div className="absolute inset-0 -z-10" aria-hidden="true">
|
||||
<div className="hp-aurora hp-aurora--a" style={blob('#138ba0', '48vw', '-10%', '-10%')} />
|
||||
<div className="hp-aurora hp-aurora--b" style={blob('#10d48a', '38vw', '45%', '20%')} />
|
||||
<div className="hp-aurora hp-aurora--c" style={blob('#d8b64a', '30vw', '70%', '-5%')} />
|
||||
|
||||
<svg
|
||||
className="absolute -right-[20vw] top-1/2 h-[120vw] w-[120vw] -translate-y-1/2 md:-right-[10vw] md:h-[80vw] md:w-[80vw]"
|
||||
viewBox="0 0 400 400"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="hp-dove-glow">
|
||||
<stop offset="0%" stopColor="#9fe7d0" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="#9fe7d0" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* The rings are faint; the dove at their centre is not, so
|
||||
the opacity sits on the rings rather than the whole SVG. */}
|
||||
<g opacity="0.16">
|
||||
<g className="hp-rings" fill="none" stroke="#ffffff">
|
||||
{[40, 70, 100, 130, 160, 190].map((r, i) => (
|
||||
<circle key={r} cx="200" cy="200" r={r} strokeWidth={i % 2 ? 0.6 : 1.2} strokeDasharray={i % 2 ? '2 6' : undefined} />
|
||||
))}
|
||||
<circle cx="390" cy="200" r="4" fill="#9fe7d0" stroke="none" />
|
||||
<circle cx="200" cy="40" r="3" fill="#5ce7ff" stroke="none" />
|
||||
</g>
|
||||
<g className="hp-rings hp-rings--reverse" fill="none" stroke="#9fe7d0">
|
||||
<circle cx="200" cy="200" r="115" strokeWidth="0.8" strokeDasharray="1 10" />
|
||||
<circle cx="85" cy="200" r="3.5" fill="#ffffff" stroke="none" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Outside the rotating groups, so it stays upright while the
|
||||
rings turn around it. 64 × 42.7 at the centre fits inside
|
||||
the innermost ring (r 40) with room to float. */}
|
||||
<circle cx="200" cy="200" r="46" fill="url(#hp-dove-glow)" />
|
||||
<g className="hp-dove" style={{ color: '#ffffff' }} opacity="0.9">
|
||||
<DoveMark x="168" y="178.65" width="64" height="42.7" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-40"
|
||||
style={{ background: `linear-gradient(to bottom, transparent, ${DEEP})` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function blob(color: string, size: string, left: string, top: string): CSSProperties {
|
||||
return { background: color, width: size, height: size, left, top }
|
||||
}
|
||||
|
||||
/* ── Photo backdrop ──────────────────────────────────────────── */
|
||||
|
||||
function PhotoBackdrop({ slides, seconds }: { slides: HeroSlide[]; seconds: number }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const ms = Math.max(3, seconds) * 1000
|
||||
const current = slides[index % slides.length]
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || slides.length < 2) return
|
||||
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % slides.length), ms)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [index, paused, ms, slides.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 -z-10"
|
||||
style={{ '--hp-slide-ms': `${ms}ms` } as CSSProperties}
|
||||
>
|
||||
{slides.map((slide, i) => (
|
||||
<div
|
||||
key={`${slide.media}-${i}`}
|
||||
className={`hp-slide ${i === index ? 'hp-slide--on' : ''}`}
|
||||
aria-hidden={i !== index}
|
||||
>
|
||||
<img src={heroPhoto(slide.media) ?? ''} alt={slide.alt ?? ''} loading={i === 0 ? 'eager' : 'lazy'} />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(100deg, rgba(4,38,46,0.92) 0%, rgba(4,38,46,0.65) 45%, rgba(4,38,46,0.15) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-6 z-10 mx-auto flex max-w-7xl flex-wrap items-end gap-4 px-6">
|
||||
{current?.caption && (
|
||||
<p className="max-w-md rounded-xl bg-black/35 px-4 py-2 text-sm text-white/90 backdrop-blur">
|
||||
{current.link_url ? (
|
||||
<HomeLink url={current.link_url} className="hover:underline">
|
||||
{current.caption} →
|
||||
</HomeLink>
|
||||
) : (
|
||||
current.caption
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{slides.length > 1 && (
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={`${slide.media}-${i}`}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||
aria-current={i === index}
|
||||
className="h-1.5 w-10 overflow-hidden rounded-full bg-white/25"
|
||||
>
|
||||
<span
|
||||
// Re-keyed per index so the fill restarts on every change.
|
||||
key={`${index}-${i}`}
|
||||
className={`hp-progress block h-full bg-white ${
|
||||
i < index ? 'hp-progress--done' : i === index ? 'hp-progress--run' : ''
|
||||
} ${paused ? 'hp-progress--paused' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
aria-label={paused ? 'Play slideshow' : 'Pause slideshow'}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/40 text-xs text-white hover:bg-white/10"
|
||||
>
|
||||
{paused ? '▶' : '❚❚'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Livestream ──────────────────────────────────────────────── */
|
||||
|
||||
function LiveFrame({ src, url, title }: { src: string | null; url: string; title?: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-white/15 bg-black shadow-2xl"
|
||||
style={{ boxShadow: '0 30px 80px -20px rgba(16, 212, 138, 0.35)' }}
|
||||
>
|
||||
<div className="aspect-video w-full">
|
||||
{src ? (
|
||||
<iframe
|
||||
src={src}
|
||||
title={title || 'Livestream'}
|
||||
className="h-full w-full"
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-white">
|
||||
<span className="hp-live-dot h-4 w-4 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||
<HomeLink
|
||||
url={url}
|
||||
className="rounded-full bg-white px-6 py-3 font-semibold text-[#04262e] hover:scale-105"
|
||||
>
|
||||
Watch live
|
||||
</HomeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
HOME LINK
|
||||
|
||||
Every link on the front page is typed into the admin, so any of
|
||||
them can be a route (/retreats), an anchor on this page
|
||||
(#connect) or somewhere else entirely. One component decides
|
||||
which element that is, so the hero buttons, photo captions and
|
||||
pathfinder actions can't disagree about it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type HomeLinkProps = {
|
||||
url: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const isExternal = (url: string) => /^[a-z][a-z0-9+.-]*:/i.test(url)
|
||||
|
||||
export default function HomeLink({ url, className, style, children }: HomeLinkProps) {
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
return (
|
||||
<Link to={url} className={className} style={style}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// mailto: and tel: open an app, not a tab.
|
||||
const newTab = isExternal(url) && !/^(mailto|tel):/i.test(url)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className={className}
|
||||
style={style}
|
||||
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
NEXT EVENT COUNTDOWN
|
||||
|
||||
A strip under the hero: the next event, and how long until it.
|
||||
The API picks the event (pinned in the admin, or the next one that
|
||||
hasn't ended); this works out the moment to count to.
|
||||
|
||||
For a one-off event that's local midnight on starts_on — dates
|
||||
here are calendar dates with no time attached. For a series it's
|
||||
the next meeting, at the series' start time when it has one, so a
|
||||
weekly class counts down to Tuesday 7pm rather than to a start
|
||||
date months in the past.
|
||||
|
||||
Once the moment passes and the event hasn't ended, the strip says
|
||||
it's happening now instead of counting below zero.
|
||||
|
||||
The strip's heading is the event itself, so the section title and
|
||||
blurb from the admin aren't drawn here.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { eventHref } from '../../../lib/hrefs.ts'
|
||||
import { upcomingOccurrences } from '../../../lib/eventSeries.ts'
|
||||
import type { CountdownEvent } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
|
||||
/* Local midnight (or HH:MM) on a 'YYYY-MM-DD'. */
|
||||
function localMoment(date: string, time?: string | null): Date | null {
|
||||
const [y, m, d] = date.split('-').map(Number)
|
||||
if (!y || !m || !d) return null
|
||||
const [hh, mm] = (time ?? '00:00').split(':').map(Number)
|
||||
return new Date(y, m - 1, d, hh || 0, mm || 0)
|
||||
}
|
||||
|
||||
function target(event: CountdownEvent): Date | null {
|
||||
if (event.series) {
|
||||
const next = upcomingOccurrences(event.series, event.starts_on, event.ends_on, 1)[0]
|
||||
return next ? localMoment(next, event.series.start_time) : null
|
||||
}
|
||||
return event.starts_on ? localMoment(event.starts_on) : null
|
||||
}
|
||||
|
||||
function useNow(intervalMs: number) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), intervalMs)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [intervalMs])
|
||||
return now
|
||||
}
|
||||
|
||||
/* `overlap` tucks the strip up over the hero's bottom edge, which
|
||||
only makes sense when it's the first band after the hero. The
|
||||
admin can move it anywhere, so Home decides. */
|
||||
export default function NextEventCountdown({
|
||||
event,
|
||||
overlap,
|
||||
}: {
|
||||
event: CountdownEvent
|
||||
overlap: boolean
|
||||
}) {
|
||||
const now = useNow(1000)
|
||||
const when = target(event)
|
||||
const accent = event.color || TEAL
|
||||
|
||||
const remaining = when ? when.getTime() - now : 0
|
||||
const live = !when || remaining <= 0
|
||||
|
||||
const parts = [
|
||||
['days', Math.floor(remaining / 86_400_000)],
|
||||
['hrs', Math.floor(remaining / 3_600_000) % 24],
|
||||
['min', Math.floor(remaining / 60_000) % 60],
|
||||
['sec', Math.floor(remaining / 1000) % 60],
|
||||
] as const
|
||||
|
||||
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Next event"
|
||||
className={`relative z-20 px-6 ${overlap ? '-mt-12' : 'py-12'}`}
|
||||
>
|
||||
<Link
|
||||
to={eventHref(event.id)}
|
||||
className="group mx-auto flex max-w-6xl flex-col gap-6 rounded-3xl border bg-white/95 p-6 shadow-2xl backdrop-blur transition-transform duration-300 hover:-translate-y-1 md:flex-row md:items-center md:p-8"
|
||||
style={{ borderColor: `${accent}55` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.25em]" style={{ color: accent }}>
|
||||
{live ? 'Happening now' : 'Next up'}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-display text-2xl font-extrabold text-[#073d4a] md:text-3xl">
|
||||
{event.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#4a6b72]">
|
||||
{[event.theme && `“${event.theme}”`, event.date_label, where]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!live && (
|
||||
<div className="flex gap-2 md:gap-3" role="timer" aria-live="off">
|
||||
{parts.map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex w-16 flex-col items-center rounded-2xl py-3 text-white md:w-20"
|
||||
style={{ background: `linear-gradient(160deg, ${accent}, #073d4a)` }}
|
||||
>
|
||||
<span className="font-display text-2xl font-bold tabular-nums md:text-3xl">
|
||||
{String(value).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="text-[0.65rem] uppercase tracking-widest text-white/75">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="self-start text-sm font-semibold transition-transform group-hover:translate-x-1 md:self-center"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Details →
|
||||
</span>
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PATHFINDER — "Find your way in"
|
||||
|
||||
The connect section as a question rather than a wall of forms:
|
||||
pick what you're here for, and that path's actions arrive. Paths
|
||||
and actions are the admin's (Front page → Find your way in);
|
||||
a path with no actions never reaches this component.
|
||||
|
||||
The choices are a real tablist: arrow keys move between them,
|
||||
Home and End jump to the ends, and only the selected tab is in
|
||||
the tab order. Selection follows focus, which is right when
|
||||
showing a panel costs nothing.
|
||||
|
||||
Re-keying the panel on the selected index is what replays the
|
||||
entrance animation for each choice.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useId, useRef, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import HomeLink, { isExternal } from './HomeLink.tsx'
|
||||
import type { FrontPagePath } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
type PathfinderProps = {
|
||||
id: string
|
||||
title: string
|
||||
blurb?: string | null
|
||||
paths: FrontPagePath[]
|
||||
}
|
||||
|
||||
/* One accent per position, cycling. Paths are data; colour is
|
||||
presentation, so it's assigned here rather than stored. */
|
||||
const ACCENTS = ['#138ba0', '#10a36e', '#c7972b', '#7a5ea8', '#d0643c']
|
||||
|
||||
export default function Pathfinder({ id, title, blurb, paths }: PathfinderProps) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
const tabs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const base = useId().replace(/:/g, '')
|
||||
|
||||
if (paths.length === 0) return null
|
||||
|
||||
const path = paths[Math.min(selected, paths.length - 1)]
|
||||
const accent = ACCENTS[selected % ACCENTS.length]
|
||||
|
||||
const focus = (index: number) => {
|
||||
const next = (index + paths.length) % paths.length
|
||||
setSelected(next)
|
||||
tabs.current[next]?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: selected + 1,
|
||||
ArrowDown: selected + 1,
|
||||
ArrowLeft: selected - 1,
|
||||
ArrowUp: selected - 1,
|
||||
Home: 0,
|
||||
End: paths.length - 1,
|
||||
}
|
||||
if (!(e.key in moves)) return
|
||||
e.preventDefault()
|
||||
focus(moves[e.key])
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="py-24" style={{ background: '#ffffff' }}>
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.3em] text-[#138ba0]">
|
||||
I’m looking to…
|
||||
</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-4 text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={title}
|
||||
aria-orientation="vertical"
|
||||
className="mt-10 flex flex-col gap-3"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{paths.map((option, index) => {
|
||||
const on = index === selected
|
||||
const color = ACCENTS[index % ACCENTS.length]
|
||||
return (
|
||||
<button
|
||||
key={`${option.label}-${index}`}
|
||||
ref={(el) => {
|
||||
tabs.current[index] = el
|
||||
}}
|
||||
id={`${base}-tab-${index}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={on}
|
||||
aria-controls={`${base}-panel`}
|
||||
tabIndex={on ? 0 : -1}
|
||||
onClick={() => setSelected(index)}
|
||||
className="group flex items-center gap-4 rounded-2xl border-2 px-5 py-4 text-left transition-all duration-300"
|
||||
style={{
|
||||
borderColor: on ? color : 'rgba(19,139,160,0.12)',
|
||||
background: on ? `${color}12` : '#ffffff',
|
||||
transform: on ? 'translateX(8px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ background: on ? color : `${color}1f` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{option.icon || '•'}
|
||||
</span>
|
||||
<span className="font-display text-xl font-bold" style={{ color: on ? color : '#073d4a' }}>
|
||||
{option.label}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-xl transition-opacity"
|
||||
style={{ color, opacity: on ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key={selected}
|
||||
id={`${base}-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${base}-tab-${selected}`}
|
||||
className="relative overflow-hidden rounded-[2rem] p-8 md:p-10 lg:col-span-7"
|
||||
style={{ background: `linear-gradient(155deg, ${accent}14, ${accent}05 60%, #ffffff)` }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute -right-6 -top-10 select-none text-[10rem] leading-none opacity-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{path.icon}
|
||||
</span>
|
||||
|
||||
{path.blurb && (
|
||||
<p className="hp-pop relative max-w-md font-display text-2xl font-semibold leading-snug text-[#073d4a]">
|
||||
{path.blurb}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="relative mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{path.actions.map((action, index) => (
|
||||
<li
|
||||
key={`${action.url}-${index}`}
|
||||
className="hp-pop"
|
||||
style={{ animationDelay: `${120 + index * 90}ms` }}
|
||||
>
|
||||
<HomeLink
|
||||
url={action.url}
|
||||
className="group flex h-full flex-col rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
>
|
||||
<span className="flex items-start gap-2 font-display text-lg font-bold text-[#073d4a]">
|
||||
{action.label}
|
||||
<span
|
||||
className="ml-auto transition-transform group-hover:translate-x-1"
|
||||
style={{ color: accent }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isExternal(action.url) ? '↗' : '→'}
|
||||
</span>
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className="mt-2 text-sm leading-relaxed text-[#4a6b72]">
|
||||
{action.description}
|
||||
</span>
|
||||
)}
|
||||
</HomeLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
RETREATS BAND
|
||||
|
||||
The National Retreats carousel from the Retreats page, as-is:
|
||||
same component, same filter, so an event edited in the admin
|
||||
shows up identically in both places. This file only adds the
|
||||
front page's heading and a way through to the full page.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import EventListCards from '../EventList-Cards.tsx'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
|
||||
type RetreatsBandProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
|
||||
return (
|
||||
<section id={id} className="overflow-hidden py-24" style={{ background: '#eef9fb' }}>
|
||||
<div className="mx-auto mb-12 flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||
<div>
|
||||
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
<Link
|
||||
to="/retreats"
|
||||
className="ml-auto rounded-full border px-5 py-2 text-sm font-semibold transition-colors hover:bg-white"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
All retreats →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EventListCards section="national" type="retreat" view="carousel" accent={TEAL} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
STATS BAND
|
||||
|
||||
The numbers from the admin's Front page editor, counted by the
|
||||
API or typed in. Each one counts up from zero the first time the
|
||||
band scrolls into view; a value that isn't a plain number
|
||||
("Since 2004", "Coast to coast") just appears.
|
||||
|
||||
Reduced motion skips the count and shows the number.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { FrontPageStat } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
const COUNT_MS = 1400
|
||||
|
||||
type StatsBandProps = {
|
||||
id: string
|
||||
title: string
|
||||
blurb?: string | null
|
||||
stats: FrontPageStat[]
|
||||
}
|
||||
|
||||
export default function StatsBand({ id, title, blurb, stats }: StatsBandProps) {
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const [seen, setSeen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || seen) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setSeen(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.35 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [seen])
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="relative overflow-hidden py-24"
|
||||
style={{ background: 'linear-gradient(160deg, #073d4a 0%, #04262e 70%)' }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute -left-40 -top-40 h-[32rem] w-[32rem] rounded-full opacity-25 blur-3xl"
|
||||
style={{ background: '#10d48a' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6">
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-[0.3em] text-[#9fe7d0]">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-2xl text-white/70">{blurb}</p>}
|
||||
|
||||
<dl className="mt-12 grid grid-cols-2 gap-x-8 gap-y-14 md:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={`${stat.label}-${index}`}
|
||||
className={`hp-stat border-l border-white/15 pl-6 ${seen ? 'hp-stat--in' : ''}`}
|
||||
style={{ transitionDelay: `${index * 120}ms` }}
|
||||
>
|
||||
<dd className="font-display text-5xl font-extrabold leading-none text-white md:text-6xl">
|
||||
<CountUp value={stat.value} run={seen} />
|
||||
{stat.suffix && <span className="text-[#9fe7d0]">{stat.suffix}</span>}
|
||||
</dd>
|
||||
<dt className="mt-3 text-sm font-semibold uppercase tracking-widest text-white/70">
|
||||
{stat.label}
|
||||
</dt>
|
||||
{stat.note && <p className="mt-1 text-sm text-white/50">{stat.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* "1,200" counts to 1,200 and keeps its comma; "12.5" keeps its
|
||||
decimal. Anything that isn't just a number renders as given. */
|
||||
function CountUp({ value, run }: { value: string; run: boolean }) {
|
||||
const numeric = /^\d[\d,]*(\.\d+)?$/.test(value)
|
||||
const target = numeric ? Number(value.replace(/,/g, '')) : 0
|
||||
const decimals = value.split('.')[1]?.length ?? 0
|
||||
const grouped = value.includes(',')
|
||||
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const [shown, setShown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!numeric || !run || reduced) return
|
||||
let frame = 0
|
||||
const start = performance.now()
|
||||
const tick = (t: number) => {
|
||||
const p = Math.min(1, (t - start) / COUNT_MS)
|
||||
// Ease out: fast at first, settling onto the number.
|
||||
setShown(target * (1 - Math.pow(1 - p, 3)))
|
||||
if (p < 1) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [numeric, run, reduced, target])
|
||||
|
||||
if (!numeric) return <>{value}</>
|
||||
|
||||
const n = reduced || !run ? (run ? target : 0) : shown
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: grouped || target >= 10_000,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FRONT PAGE MOTION
|
||||
|
||||
Keyframes and the few rules Tailwind utilities can't express.
|
||||
Everything that moves on its own stops under
|
||||
prefers-reduced-motion; the page still reads the same without it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Hero: brand mode aurora ─────────────────────────────────── */
|
||||
|
||||
.hp-aurora {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.55;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hp-aurora--a { animation: hp-drift-a 22s ease-in-out infinite alternate; }
|
||||
.hp-aurora--b { animation: hp-drift-b 28s ease-in-out infinite alternate; }
|
||||
.hp-aurora--c { animation: hp-drift-c 34s ease-in-out infinite alternate; }
|
||||
|
||||
@keyframes hp-drift-a {
|
||||
from { transform: translate(-10%, -5%) scale(1); }
|
||||
to { transform: translate(15%, 10%) scale(1.25); }
|
||||
}
|
||||
@keyframes hp-drift-b {
|
||||
from { transform: translate(10%, 5%) scale(1.1); }
|
||||
to { transform: translate(-20%, -10%) scale(0.9); }
|
||||
}
|
||||
@keyframes hp-drift-c {
|
||||
from { transform: translate(0, 10%) scale(0.9); }
|
||||
to { transform: translate(-10%, -15%) scale(1.2); }
|
||||
}
|
||||
|
||||
/* Concentric rings: many circles, one centre. */
|
||||
.hp-rings {
|
||||
animation: hp-spin 90s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.hp-rings--reverse { animation-direction: reverse; animation-duration: 140s; }
|
||||
|
||||
@keyframes hp-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* The dove at the rings' centre bobs gently rather than turning. */
|
||||
.hp-dove {
|
||||
animation: hp-bob 6s ease-in-out infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes hp-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* ── Hero: headline words rise in ────────────────────────────── */
|
||||
|
||||
.hp-rise {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transform: translateY(0.6em);
|
||||
animation: hp-rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hp-rise {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Hero: photos ────────────────────────────────────────────── */
|
||||
|
||||
.hp-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 1.4s ease;
|
||||
}
|
||||
.hp-slide--on { opacity: 1; }
|
||||
|
||||
.hp-slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hp-slide--on img { animation: hp-kenburns var(--hp-slide-ms, 7000ms) ease-out forwards; }
|
||||
|
||||
@keyframes hp-kenburns {
|
||||
from { transform: scale(1.12) translate(1.5%, 1%); }
|
||||
to { transform: scale(1) translate(0, 0); }
|
||||
}
|
||||
|
||||
.hp-progress {
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
.hp-progress--run { animation: hp-fill var(--hp-slide-ms, 7000ms) linear forwards; }
|
||||
.hp-progress--done { transform: scaleX(1); }
|
||||
.hp-progress--paused { animation-play-state: paused; }
|
||||
|
||||
@keyframes hp-fill {
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
/* ── Hero: LIVE ──────────────────────────────────────────────── */
|
||||
|
||||
.hp-live-dot {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.7);
|
||||
animation: hp-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hp-pulse {
|
||||
to { box-shadow: 0 0 0 12px rgba(255, 77, 77, 0); }
|
||||
}
|
||||
|
||||
/* ── Timeline rail ───────────────────────────────────────────── */
|
||||
|
||||
.hp-rail {
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.hp-rail::-webkit-scrollbar { display: none; }
|
||||
.hp-rail--dragging { cursor: grabbing; scroll-snap-type: none; user-select: none; }
|
||||
.hp-rail > * { scroll-snap-align: start; }
|
||||
|
||||
/* ── Pathfinder: actions arrive one after another ────────────── */
|
||||
|
||||
.hp-pop {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.98);
|
||||
animation: hp-pop 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hp-pop {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Stats: numbers settle in ────────────────────────────────── */
|
||||
|
||||
.hp-stat {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.hp-stat--in { opacity: 1; transform: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hp-aurora,
|
||||
.hp-rings,
|
||||
.hp-dove,
|
||||
.hp-slide--on img,
|
||||
.hp-live-dot {
|
||||
animation: none;
|
||||
}
|
||||
.hp-rise,
|
||||
.hp-pop {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.hp-stat {
|
||||
transition: none;
|
||||
}
|
||||
.hp-slide {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
13
src/react-css.d.ts
vendored
13
src/react-css.d.ts
vendored
|
|
@ -1,13 +0,0 @@
|
|||
/* CSS custom properties set through a `style` prop. React's
|
||||
CSSProperties only knows the standard ones, so each variable the
|
||||
site sets inline is declared here once rather than cast at every
|
||||
use. */
|
||||
|
||||
import "react";
|
||||
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
/** The Instagram icon's hover fill; read by .ig-icon in index.css. */
|
||||
"--ig-fill"?: string;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue