From 5efdafbb9730e3f7042e48b55592d2abc6adfed1 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 02:35:46 -0500 Subject: [PATCH] v1.3 - added an sqlite db and built data structure --- server/package.json | 18 + server/src/db.js | 120 ++++ server/src/index.js | 84 +++ server/src/migrate-cli.js | 15 + server/src/migrations/001_init.sql | 16 + server/src/migrations/002_schema.sql | 700 ++++++++++++++++++ server/src/rateLimit.js | 49 ++ server/src/routes/content.js | 397 +++++++++++ server/src/routes/feedback.js | 130 ++++ server/src/seed.js | 398 +++++++++++ server/src/shape.js | 147 ++++ src/components/ArrowLink.tsx | 30 + src/components/Banner.tsx | 2 +- src/components/PeopleTiles.css | 365 ++++++++++ src/components/PeopleTiles.tsx | 375 ++++++++++ src/{ => data}/bannerConfig.js | 0 src/data/chapters.js | 146 ++++ src/data/eventData.js | 54 ++ src/{ => data}/events.js | 10 +- src/data/mapGrid.js | 174 +++++ src/data/organizations.js | 90 +++ src/index.css | 140 ++++ src/lib/api.js | 102 +++ src/lib/sections.tsx | 78 ++ src/lib/useResource.js | 52 ++ src/pages/Chapters.tsx | 717 ------------------- src/pages/Community.tsx | 112 +-- src/pages/Feedback.tsx | 287 +------- src/pages/Leadership.tsx | 64 +- src/pages/Retreats.tsx | 633 ++--------------- src/pages/chapters.js | 353 --------- src/pages/sections/EventList-Cards.tsx | 568 +++++++++++++++ src/pages/sections/FeedbackForm.tsx | 494 +++++++++++++ src/pages/sections/OrgList-Card.tsx | 240 +++++++ src/pages/sections/OrgList-Map.tsx | 907 ++++++++++++++++++++++++ src/pages/sections/OrgList-Vertical.tsx | 326 +++++++++ vite.config.ts | 9 + 37 files changed, 6414 insertions(+), 1988 deletions(-) create mode 100644 server/package.json create mode 100644 server/src/db.js create mode 100644 server/src/index.js create mode 100644 server/src/migrate-cli.js create mode 100644 server/src/migrations/001_init.sql create mode 100644 server/src/migrations/002_schema.sql create mode 100644 server/src/rateLimit.js create mode 100644 server/src/routes/content.js create mode 100644 server/src/routes/feedback.js create mode 100644 server/src/seed.js create mode 100644 server/src/shape.js create mode 100644 src/components/ArrowLink.tsx create mode 100644 src/components/PeopleTiles.css create mode 100644 src/components/PeopleTiles.tsx rename src/{ => data}/bannerConfig.js (100%) create mode 100644 src/data/chapters.js create mode 100644 src/data/eventData.js rename src/{ => data}/events.js (98%) create mode 100644 src/data/mapGrid.js create mode 100644 src/data/organizations.js create mode 100644 src/lib/api.js create mode 100644 src/lib/sections.tsx create mode 100644 src/lib/useResource.js delete mode 100644 src/pages/Chapters.tsx delete mode 100644 src/pages/chapters.js create mode 100644 src/pages/sections/EventList-Cards.tsx create mode 100644 src/pages/sections/FeedbackForm.tsx create mode 100644 src/pages/sections/OrgList-Card.tsx create mode 100644 src/pages/sections/OrgList-Map.tsx create mode 100644 src/pages/sections/OrgList-Vertical.tsx diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f898f59 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "ngu-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "migrate": "node src/migrate-cli.js" + }, + "dependencies": { + "@hono/node-server": "^1.0.0", + "hono": "^4.0.0" + } +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..ad669b6 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,120 @@ +/* ═══════════════════════════════════════════════════════════════ + DATABASE + + One SQLite file, opened once at boot and held for the life of + the process. Node's own sqlite module is used when it's there + (Node 24+), better-sqlite3 otherwise. Their APIs overlap enough + that everything below works against either, as long as you: + + • use positional ? parameters, never named ones + • pass 0/1 for booleans, never true/false + • use tx() rather than db.transaction() + + Those three rules are the whole compatibility story. + ═══════════════════════════════════════════════════════════════ */ + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(HERE, "migrations"); + +/* ── Driver selection ──────────────────────────────────────── */ + +async function loadDriver() { + try { + const { DatabaseSync } = await import("node:sqlite"); + return { Driver: DatabaseSync, name: "node:sqlite" }; + } catch { + const { default: BetterSqlite3 } = await import("better-sqlite3"); + return { Driver: BetterSqlite3, name: "better-sqlite3" }; + } +} + +/* ── Open ────────────────────────────────────────────────────── + WAL readers never block the writer, which matters the + moment a feedback POST lands mid page-load + NORMAL fsync on checkpoint rather than every commit; safe + under WAL, and much faster + busy wait rather than throw if something else holds the + write lock (a backup, usually) + ───────────────────────────────────────────────────────────── */ + +export async function openDatabase(path) { + const { Driver, name } = await loadDriver(); + const db = new Driver(path); + + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec("PRAGMA busy_timeout = 5000"); + + db.driverName = name; + return db; +} + +/* ── Transactions ────────────────────────────────────────────── + node:sqlite has no db.transaction(), so do it by hand. Runs + fn() and commits, or rolls back and rethrows. + ───────────────────────────────────────────────────────────── */ + +export function tx(db, fn) { + db.exec("BEGIN"); + try { + const result = fn(); + db.exec("COMMIT"); + return result; + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* already rolled back */ + } + throw err; + } +} + +/* ── Migrations ──────────────────────────────────────────────── + Files are NNN_name.sql. The leading number is the version. + PRAGMA user_version tracks how far we've got, so there's no + bookkeeping table and no ordering ambiguity. + + Migrations only ever go forward. To undo something, write a + new migration. + ───────────────────────────────────────────────────────────── */ + +export function migrate(db, { log = console.log } = {}) { + const current = db.prepare("PRAGMA user_version").get().user_version; + + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + + let applied = 0; + + for (const file of files) { + const version = Number.parseInt(file.slice(0, 3), 10); + + if (!Number.isInteger(version) || version < 1) { + throw new Error(`Migration "${file}" must start with a number, e.g. 001_`); + } + if (version <= current) continue; + + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + + tx(db, () => { + db.exec(sql); + // Not parameterisable, but version is a validated integer. + db.exec(`PRAGMA user_version = ${version}`); + }); + + log(`migrated → ${file}`); + applied += 1; + } + + const final = db.prepare("PRAGMA user_version").get().user_version; + if (applied === 0) log(`schema up to date (v${final})`); + + return final; +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..e422284 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,84 @@ +/* ═══════════════════════════════════════════════════════════════ + NGU API + + Binds to localhost only. nginx is the only thing that talks to + it, which is what lets the routes trust X-Forwarded-For and skip + CORS entirely — in production the API and the site share an + origin, and in development Vite proxies /api so they share one + there too. + ═══════════════════════════════════════════════════════════════ */ + +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; + +import { openDatabase, migrate } from "./db.js"; +import { rateLimit } from "./rateLimit.js"; +import content from "./routes/content.js"; +import feedback from "./routes/feedback.js"; + +const HOST = process.env.HOST ?? "127.0.0.1"; +const PORT = Number(process.env.PORT ?? 3001); +const DB_PATH = process.env.DB_PATH ?? "./ngu.db"; + +/* ── Boot ────────────────────────────────────────────────────── */ + +const db = await openDatabase(DB_PATH); +const version = migrate(db); + +console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`); + +/* ── App ─────────────────────────────────────────────────────── */ + +const app = new Hono(); + +app.use("*", logger()); + +app.use("*", async (c, next) => { + c.set("db", db); + await next(); +}); + +app.get("/api/health", (c) => + c.json({ ok: true, schema: version, driver: db.driverName }), +); + +app.route("/api", content); + +// Tighter limit on the write path than anything else gets. +app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); +app.route("/api/feedback", feedback); + +app.notFound((c) => c.json({ error: "Not found" }, 404)); + +app.onError((err, c) => { + console.error(err); + // Never leak internals to the browser. + return c.json({ error: "Something went wrong." }, 500); +}); + +/* ── Serve ───────────────────────────────────────────────────── */ + +const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) => + console.log(`listening http://${info.address}:${info.port}`), +); + +/* ── Shutdown ────────────────────────────────────────────────── + systemd sends SIGTERM on stop and restart. Closing the handle + flushes the WAL cleanly, which saves a recovery pass on the + next boot. + ───────────────────────────────────────────────────────────── */ + +for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + console.log(`${signal} — shutting down`); + server.close(() => { + try { + db.close(); + } catch { + /* nothing useful to do here */ + } + process.exit(0); + }); + }); +} diff --git a/server/src/migrate-cli.js b/server/src/migrate-cli.js new file mode 100644 index 0000000..82cdba3 --- /dev/null +++ b/server/src/migrate-cli.js @@ -0,0 +1,15 @@ +/* Run migrations without starting the server. + Useful in a deploy script, before restarting the unit. + + DB_PATH=/var/lib/ngu/ngu.db pnpm migrate +*/ + +import { openDatabase, migrate } from "./db.js"; + +const DB_PATH = process.env.DB_PATH ?? "./ngu.db"; + +const db = await openDatabase(DB_PATH); +const version = migrate(db); +db.close(); + +console.log(`${DB_PATH} is at schema v${version}`); diff --git a/server/src/migrations/001_init.sql b/server/src/migrations/001_init.sql new file mode 100644 index 0000000..31d3f51 --- /dev/null +++ b/server/src/migrations/001_init.sql @@ -0,0 +1,16 @@ +-- 001_init.sql +-- +-- Placeholder so the runner has something to do on first boot and +-- you can confirm the plumbing works end to end. The real tables +-- (regions, region_states, state_grid, chapters, events, feedback) +-- land in 002. +-- +-- Once 002 exists you can leave this file alone. Never edit a +-- migration that has already run anywhere; write the next one. + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now')); diff --git a/server/src/migrations/002_schema.sql b/server/src/migrations/002_schema.sql new file mode 100644 index 0000000..e57a0fb --- /dev/null +++ b/server/src/migrations/002_schema.sql @@ -0,0 +1,700 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 002_schema.sql +-- +-- Four things own a card and a page: organizations, events, people +-- and teams. They share two tables — content_blocks for long-form +-- description and links for buttons and socials — so a bio, an +-- event description and a region's page all render through one +-- component. +-- +-- Tables are STRICT, so a column declared TEXT refuses an integer +-- rather than quietly storing one. Worth it when the eventual +-- writer is a web form. +-- ═══════════════════════════════════════════════════════════════ + + +-- ═══════════════════════════════════════════════════════════════ +-- ORGANIZATIONS +-- ═══════════════════════════════════════════════════════════════ + +-- Regions, chapters, partners and NGU itself. They differ in a +-- handful of fields, which live in side tables keyed by the same +-- id, so events get one real foreign key to their host instead of +-- a type/id pair SQLite can't check. +-- +-- location_label is the display override for what the structured +-- fields can't express: "Online", "Various venues", "Unity Village, +-- MO". Read it first, fall back to composing from the parts. +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood' + kind TEXT NOT NULL + CHECK (kind IN ('national', 'region', 'chapter', 'partner')), + + name TEXT NOT NULL, + short_name TEXT, + tagline TEXT, -- one line, for the card + color TEXT, + logo TEXT, -- filename in public/org-logos/ + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, -- US only + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order); +CREATE INDEX organizations_state_idx ON organizations (state_code); + + +CREATE TABLE regions ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + scope TEXT NOT NULL + CHECK (scope IN ('domestic', 'international', 'virtual')), + map_note TEXT +) STRICT; + + +-- Which map areas a region covers, and how much of each. +-- +-- area_code is a plain string matched at render time against the +-- keys in mapGrid.js. No foreign key, because the thing it points +-- at isn't in this database. An unrecognised code paints nothing, +-- which is how Africa and the UK exist as regions with no tile. +-- +-- Replaces both GROUPS.states and SPLITS. A region owning a whole +-- area has share 1.0 and no edge. A shared area gets one row per +-- region, each naming its own slice, so there's no primary and +-- secondary to keep straight. +CREATE TABLE region_areas ( + region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE, + area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA' + share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1), + edge TEXT CHECK (edge IN ('top', 'bottom')), + note TEXT, -- 'north', 'Salt Lake City area' + PRIMARY KEY (region_id, area_code) +) STRICT; + +CREATE INDEX region_areas_area_idx ON region_areas (area_code); + + +-- region_id is stored rather than derived from the state. Deriving +-- it is what forced the per-chapter override in split states; the +-- admin form should default it from the state and only ask when the +-- state has more than one row in region_areas. +-- +-- No `leads` column. Who runs a chapter is an affiliation, exactly +-- as it is for every other organization. +CREATE TABLE chapters ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + region_id TEXT REFERENCES regions (id) ON DELETE SET NULL, + meets TEXT, -- '2nd Sundays, 6:00pm' + started TEXT -- 'Since 2021' +) STRICT; + +CREATE INDEX chapters_region_idx ON chapters (region_id); + + +-- Partners get no side table. Everything they need is already on +-- organizations, and a table holding nothing but a primary key is +-- a place for confusion rather than data. + + +-- ═══════════════════════════════════════════════════════════════ +-- EVENTS +-- ═══════════════════════════════════════════════════════════════ + +-- Sections are defined in Retreats.jsx, which owns their titles, +-- accents, default colours and backgrounds. This table exists only +-- so section_id can be a real foreign key: an unrecognised value +-- would make an event vanish from the page with no error anywhere, +-- which is a bug someone hunts for an hour. +-- +-- `name` is an internal label for the eventual admin dropdown. The +-- site never renders it. +CREATE TABLE event_sections ( + id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner' + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + + +-- Dates are stored three ways on purpose: +-- +-- starts_on / ends_on ISO dates, nullable. What sorting and the +-- upcoming/past split run on. +-- date_label what the card shows. Real data includes +-- "March/April 2026", which no date type +-- holds and no formatter should reproduce. +-- status an override. Null derives from ends_on, +-- so there's no flag to remember to flip. +CREATE TABLE events ( + id TEXT PRIMARY KEY, + section_id TEXT NOT NULL REFERENCES event_sections (id), + host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL, + + title TEXT NOT NULL, + theme TEXT, + tagline TEXT, + + starts_on TEXT, -- 'YYYY-MM-DD' + ends_on TEXT, + date_label TEXT, + status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')), + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + org_logo TEXT, -- null → host's logo + event_logo TEXT, + color TEXT, -- null → host's, then the page's + gradient TEXT, + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order); +CREATE INDEX events_host_idx ON events (host_org_id); +CREATE INDEX events_date_idx ON events (starts_on); + + +-- ═══════════════════════════════════════════════════════════════ +-- PEOPLE +-- ═══════════════════════════════════════════════════════════════ + +-- Public by design. Everything in this table can appear on a card, +-- and is_published = 0 is the only thing between a row and the +-- open web — hence the default of 0, unlike organizations. +-- Anything that must never be served lives in person_private, so a +-- careless SELECT * can't leak it. +-- +-- Bio goes in content_blocks: 'card' slot for the two lines under +-- a photo, 'body' slot for the full page with headings and lists. +-- Socials and personal sites go in links. +CREATE TABLE people ( + id TEXT PRIMARY KEY, -- slug: 'jane-doe' + display_name TEXT NOT NULL, -- 'Jane Doe' + sort_name TEXT, -- 'Doe, Jane' — list ordering + pronouns TEXT, -- 'she/her' + tagline TEXT, -- fallback when no title applies + photo TEXT, -- filename in public/people/ + + public_email TEXT, -- safe to print on the site + public_phone TEXT, + + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name); + + +-- Never joined into a public response. A separate table rather than +-- extra columns so the boundary is structural instead of a rule +-- someone has to remember. +-- +-- birth_date rather than age: an age column is wrong within a year +-- of being written. Derive it when needed, and consider first +-- whether you need it at all — Planning Center already holds +-- registration data, and the least sensitive record is the one you +-- never made. +CREATE TABLE person_private ( + person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE, + birth_date TEXT, -- 'YYYY-MM-DD' + private_email TEXT, + private_phone TEXT, + address TEXT, + notes TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + + +-- ── Teams ────────────────────────────────────────────────────── +-- +-- A team belongs to exactly one organization: NGU national has a +-- Board and a Leadership Team, a region or chapter can have its +-- own. An organization with a flat structure needs none — its +-- affiliations simply carry no team_id. +-- +-- UNIQUE (id, org_id) looks redundant against the primary key, and +-- it is — except that it gives affiliations a composite foreign key +-- to point at, which is what stops someone filing a person under a +-- team belonging to a different organization. +CREATE TABLE teams ( + id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership' + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name TEXT NOT NULL, + tagline TEXT, + color TEXT, + logo TEXT, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (id, org_id) +) STRICT; + +CREATE INDEX teams_org_idx ON teams (org_id, sort_order); + + +-- ── Affiliations ─────────────────────────────────────────────── +-- +-- The leadership list for every organization on the site. A chapter +-- lead, a regional coordinator and a national board member are the +-- same kind of row; only org_id differs. +-- +-- One person can hold several: chapter lead in Lynnwood and board +-- member nationally are two rows. +-- +-- ended_on null means current. Keeping past roles rather than +-- deleting them is what makes an alumni list possible later. +-- +-- is_owner marks authority within the organization, and is +-- deliberately orthogonal to role — a board member and a chapter +-- lead can both be owners, a long-serving volunteer isn't. It +-- drives billing order on cards. It is NOT an edit permission: +-- when the admin pages arrive, who may change an organization's +-- content belongs in its own table, because the person who +-- maintains a page is often not the person who runs the chapter. +-- +-- Deleting a team that still has members fails rather than +-- silently detaching them. That's the composite foreign key doing +-- its job; clear or reassign the members first. +CREATE TABLE affiliations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + team_id TEXT, + + title TEXT, -- 'Board Chair', 'Chapter Lead' + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')), + is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)), + started_on TEXT, + ended_on TEXT, -- null = current + + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id) +) STRICT; + +CREATE INDEX affiliations_person_idx ON affiliations (person_id); +CREATE INDEX affiliations_org_idx + ON affiliations (org_id, is_public, is_owner DESC, sort_order); +CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order); + + +-- ── People at events ─────────────────────────────────────────── +-- +-- Both the public billing (speakers, leaders) and the private +-- record of who attended, distinguished by is_public rather than by +-- table. It defaults to 0, so a new row is invisible until someone +-- decides otherwise — the right way round for this. +-- +-- If attendance ever becomes real check-in data synced from +-- Planning Center, that belongs in its own table. This one is for +-- the handful of names worth remembering per event. +CREATE TABLE event_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + + role TEXT NOT NULL DEFAULT 'attendee' + CHECK (role IN ('speaker', 'leader', 'facilitator', 'host', + 'musician', 'volunteer', 'attendee')), + title TEXT, -- 'Keynote Speaker' + is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + UNIQUE (event_id, person_id, role) +) STRICT; + +CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order); +CREATE INDEX event_people_person_idx ON event_people (person_id); + + +-- ── Awards ───────────────────────────────────────────────────── +-- +-- An award exists independently of who won it, which is why it's +-- two tables and not a text column on people. +CREATE TABLE awards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + logo TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE person_awards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE, + event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented + awarded_on TEXT, + citation TEXT, + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + UNIQUE (person_id, award_id, awarded_on) +) STRICT; + +CREATE INDEX person_awards_person_idx ON person_awards (person_id); + + +-- ── Curated lists ────────────────────────────────────────────── +-- +-- Teams and affiliations are structural: they describe how an +-- organization is actually run. Lists are editorial: "2026 Retreat +-- Speakers", "Founders", anything a page wants to show that isn't +-- an org chart. If it turns out affiliations cover everything, this +-- pair is easy to drop — nothing depends on it. +CREATE TABLE people_lists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + blurb TEXT, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE people_list_members ( + list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + note TEXT, -- overrides tagline in this list + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (list_id, person_id) +) STRICT; + + +-- ═══════════════════════════════════════════════════════════════ +-- CONTENT BLOCKS +-- ═══════════════════════════════════════════════════════════════ + +-- Long-form description as ordered rows, shared by all four card +-- types. +-- +-- slot 'card' is the short version on the tile — an event's +-- desc_a and desc_b become two paragraph blocks here. +-- 'body' is the full page. Same renderer, different query. +-- +-- Blocks with children (list, links) use content_block_items. +-- +-- owner_kind + owner_id is polymorphic, which SQLite can't express +-- as a foreign key. The triggers below do the work a FK would. +CREATE TABLE content_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')), + sort_order INTEGER NOT NULL DEFAULT 0, + + type TEXT NOT NULL + CHECK (type IN ('heading', 'subheading', 'paragraph', + 'list', 'links', 'quote', 'image', 'divider')), + text TEXT, + media TEXT, + href TEXT +) STRICT; + +CREATE INDEX content_blocks_owner_idx + ON content_blocks (owner_kind, owner_id, slot, sort_order); + + +CREATE TABLE content_block_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + detail TEXT, + url TEXT -- null → plain list item +) STRICT; + +CREATE INDEX content_block_items_block_idx + ON content_block_items (block_id, sort_order); + + +-- ═══════════════════════════════════════════════════════════════ +-- LINKS +-- ═══════════════════════════════════════════════════════════════ + +-- Entity-level links: a Register button, an Instagram handle, a +-- personal site. Distinct from links inside a content block, which +-- are part of a sentence rather than a control. +CREATE TABLE links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + kind TEXT NOT NULL DEFAULT 'action' + CHECK (kind IN ('action', 'social', 'website', 'email')), + platform TEXT, -- 'instagram', 'discord' + label TEXT NOT NULL, + url TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)) +) STRICT; + +CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order); + + +-- ═══════════════════════════════════════════════════════════════ +-- FEEDBACK +-- ═══════════════════════════════════════════════════════════════ + +-- The only table the public can write to. +-- +-- page_path and section_id are free text rather than foreign keys +-- on purpose: they record where someone was when they wrote, and +-- that shouldn't change meaning when a route is later renamed. +CREATE TABLE feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + feedback_type TEXT NOT NULL DEFAULT 'general', + message TEXT NOT NULL, + name TEXT, + email TEXT, + + page_path TEXT, + section_id TEXT, + + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')), + admin_note TEXT, + user_agent TEXT, + ip_hash TEXT -- hashed, never the address +) STRICT; + +CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC); + + +-- ═══════════════════════════════════════════════════════════════ +-- INTEGRITY FOR THE POLYMORPHIC TABLES +-- ═══════════════════════════════════════════════════════════════ + +CREATE TRIGGER content_blocks_owner_exists +BEFORE INSERT ON content_blocks +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such team') + END; +END; + +CREATE TRIGGER links_owner_exists +BEFORE INSERT ON links +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such team') + END; +END; + +CREATE TRIGGER organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id; +END; + +CREATE TRIGGER events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id; +END; + +CREATE TRIGGER people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id; +END; + +CREATE TRIGGER teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id; +END; + + +-- ── updated_at ───────────────────────────────────────────────── +-- The WHEN guard stops the trigger recursing, and lets an explicit +-- updated_at through untouched, which matters when importing. + +CREATE TRIGGER organizations_touch +AFTER UPDATE ON organizations +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER events_touch +AFTER UPDATE ON events +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE events SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER people_touch +AFTER UPDATE ON people +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE people SET updated_at = datetime('now') WHERE id = new.id; +END; + + +-- ═══════════════════════════════════════════════════════════════ +-- VIEWS +-- ═══════════════════════════════════════════════════════════════ + +-- Events with the host resolved and the logo/colour fallbacks +-- applied, so no handler has to remember the rules. An event with +-- no colour of its own inherits its host organization's; if that's +-- null too, the page applies the section default, which is where +-- that default lives. +CREATE VIEW v_events AS +SELECT + e.*, + o.name AS host_name, + o.kind AS host_kind, + o.logo AS host_logo, + COALESCE(e.org_logo, o.logo) AS effective_org_logo, + COALESCE(e.color, o.color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN organizations o ON o.id = e.host_org_id; + + +-- Chapters flattened for the list. The API adds a map area to each +-- row using mapGrid.js; that can't happen here because the grid +-- isn't in this database. Leadership comes from v_org_leadership, +-- filtered on the chapter's id. +CREATE VIEW v_chapters AS +SELECT + o.id, o.name, o.short_name, o.tagline, o.color, o.logo, + o.venue, o.locality, o.state_code, o.country, o.location_label, + o.is_online, o.sort_order, + c.region_id, c.meets, c.started, + r.name AS region_name, + r.color AS region_color +FROM organizations o +JOIN chapters c ON c.id = o.id +LEFT JOIN organizations r ON r.id = c.region_id +WHERE o.is_published = 1; + + +-- Current, public leadership of any organization. Owners first, +-- then explicit order, then name. A chapter page, a region page and +-- the national Leadership page all read this; the only difference +-- is the org_id they filter on, and whether they group by team. +CREATE VIEW v_org_leadership AS +SELECT + a.org_id, + a.team_id, + t.name AS team_name, + t.sort_order AS team_sort_order, + a.person_id, + a.title, + a.role, + a.is_owner, + a.sort_order, + p.display_name, + p.sort_name, + p.pronouns, + p.tagline, + p.photo, + p.public_email +FROM affiliations a +JOIN people p ON p.id = a.person_id AND p.is_published = 1 +LEFT JOIN teams t ON t.id = a.team_id +WHERE a.is_public = 1 + AND a.ended_on IS NULL +ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name; + + +-- Every public affiliation a person holds, current or past. Feeds +-- the "affiliated organizations" block on a person's page, where +-- past roles are worth showing and v_org_leadership's current-only +-- filter would hide them. +CREATE VIEW v_person_affiliations AS +SELECT + a.person_id, + a.org_id, + a.team_id, + a.title, + a.role, + a.is_owner, + a.started_on, + a.ended_on, + (a.ended_on IS NULL) AS is_current, + a.sort_order, + o.name AS org_name, + o.kind AS org_kind, + o.logo AS org_logo, + o.color AS org_color, + t.name AS team_name +FROM affiliations a +JOIN organizations o ON o.id = a.org_id +LEFT JOIN teams t ON t.id = a.team_id +WHERE a.is_public = 1; + + +-- Public event billing only. Attendance rows stay out, because +-- is_public defaults to 0. +CREATE VIEW v_event_people AS +SELECT + ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order, + p.display_name, p.pronouns, p.tagline, p.photo +FROM event_people ep +JOIN people p ON p.id = ep.person_id AND p.is_published = 1 +WHERE ep.is_public = 1; diff --git a/server/src/rateLimit.js b/server/src/rateLimit.js new file mode 100644 index 0000000..5b35773 --- /dev/null +++ b/server/src/rateLimit.js @@ -0,0 +1,49 @@ +/* ═══════════════════════════════════════════════════════════════ + RATE LIMIT + + A fixed window counter held in process memory. It resets when + the service restarts and it doesn't survive a second instance, + both of which are fine for one systemd unit on one box. + + The client IP comes from X-Forwarded-For, which nginx sets. That + header is only trustworthy because nothing but nginx can reach + this port — it binds to 127.0.0.1. Don't expose the port. + ═══════════════════════════════════════════════════════════════ */ + +export function rateLimit({ windowMs = 60_000, max = 10 } = {}) { + const hits = new Map(); // ip → { count, resetAt } + + // Drop expired entries occasionally so the map can't grow forever. + setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of hits) { + if (entry.resetAt <= now) hits.delete(ip); + } + }, windowMs).unref(); + + return async (c, next) => { + const ip = + c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "unknown"; + + const now = Date.now(); + let entry = hits.get(ip); + + if (!entry || entry.resetAt <= now) { + entry = { count: 0, resetAt: now + windowMs }; + hits.set(ip, entry); + } + + entry.count += 1; + + if (entry.count > max) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return c.json( + { error: "Too many requests. Try again shortly." }, + 429, + { "Retry-After": String(retryAfter) }, + ); + } + + await next(); + }; +} diff --git a/server/src/routes/content.js b/server/src/routes/content.js new file mode 100644 index 0000000..d77c2c6 --- /dev/null +++ b/server/src/routes/content.js @@ -0,0 +1,397 @@ +/* ═══════════════════════════════════════════════════════════════ + CONTENT ROUTES — read-only, mounted under /api + + GET /events list + the section ids + GET /events/:id one event, full body, people + GET /organizations list, ?kind=region|chapter|… + GET /organizations/:id one organization's page + + Organizations are one table, so they're one endpoint. A region + and a chapter differ by a handful of fields, which arrive under + `details` rather than as separate routes — that's what lets a + list component be written once and pointed at any kind. + + Responses carry their fallbacks already resolved: an event's + `color` is its own or its host's, and `status` is derived from + the dates when it isn't set. Components read one field and don't + reimplement the rules. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; + +const content = new Hono(); + +// Content changes weekly at most, and a stale minute costs nobody +// anything. stale-while-revalidate keeps the page instant while the +// refresh happens behind it. +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const ORG_KINDS = ["national", "region", "chapter", "partner"]; + +const marks = (n) => Array(n).fill("?").join(","); + +/* ── Shapers ───────────────────────────────────────────────── */ + +function shapeEvent(row, links, cardBlocks) { + const { actions, instagram } = splitLinks(links); + + return { + id: row.id, + section_id: row.section_id, + + title: row.title, + theme: row.theme, + tagline: row.tagline, + + starts_on: row.starts_on, + ends_on: row.ends_on, + date_label: row.date_label, + status: row.effective_status, + + location_label: row.location_label, + locality: row.locality, + state_code: row.state_code, + country: row.country, + is_online: asBool(row.is_online), + + org_logo: row.effective_org_logo, + event_logo: row.event_logo, + color: row.effective_color, + gradient: row.gradient, + + host: row.host_org_id + ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind } + : null, + + description: paragraphs(cardBlocks), + links: actions, + instagram, + }; +} + +/* The common card surface every organization has, whatever kind it + is. Kind-specific fields go in `details`, attached by the caller. */ +function shapeOrganization(row, links, cardBlocks, bodyBlocks) { + const { actions, socials, website, email, instagram } = splitLinks(links); + + return { + id: row.id, + kind: row.kind, + name: row.name, + short_name: row.short_name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + + venue: row.venue, + address: row.address, + locality: row.locality, + state_code: row.state_code, + country: row.country, + location_label: row.location_label, + is_online: asBool(row.is_online), + sort_order: row.sort_order, + + description: paragraphs(cardBlocks), + blocks: bodyBlocks, + links: actions, + socials, + website, + email, + instagram, + + details: {}, + leadership: [], + }; +} + +function shapeLeader(row) { + return { + person_id: row.person_id, + display_name: row.display_name, + pronouns: row.pronouns, + title: row.title ?? row.tagline, + role: row.role, + is_owner: asBool(row.is_owner), + photo: row.photo, + public_email: row.public_email, + team_id: row.team_id, + team_name: row.team_name, + }; +} + +/* ── Kind-specific details, batched ──────────────────────────── + Each of these runs a fixed number of queries for the whole list + rather than one per organization. + ───────────────────────────────────────────────────────────── */ + +function attachRegionDetails(db, orgs) { + const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`) + .all(...ids); + + const areas = db + .prepare( + `SELECT region_id, area_code, share, edge, note + FROM region_areas + WHERE region_id IN (${marks(ids.length)}) + ORDER BY area_code, share DESC`, + ) + .all(...ids); + + // A region's chapters, enough of each for a list entry. + const children = db + .prepare( + `SELECT c.region_id, o.id, o.name, o.location_label, o.logo + FROM chapters c + JOIN organizations o ON o.id = c.id AND o.is_published = 1 + WHERE c.region_id IN (${marks(ids.length)}) + ORDER BY o.sort_order, o.name`, + ) + .all(...ids); + + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + const areasBy = new Map(); + const childrenBy = new Map(); + + for (const row of areas) { + const list = areasBy.get(row.region_id); + const entry = { + area_code: row.area_code, + share: row.share, + edge: row.edge, + note: row.note, + }; + if (list) list.push(entry); + else areasBy.set(row.region_id, [entry]); + } + + for (const row of children) { + const list = childrenBy.get(row.region_id); + const entry = { + id: row.id, + name: row.name, + location_label: row.location_label, + logo: row.logo, + }; + if (list) list.push(entry); + else childrenBy.set(row.region_id, [entry]); + } + + for (const org of orgs) { + if (org.kind !== "region") continue; + org.details = { + scope: byId[org.id]?.scope ?? null, + map_note: byId[org.id]?.map_note ?? null, + areas: areasBy.get(org.id) ?? [], + chapters: childrenBy.get(org.id) ?? [], + }; + } +} + +function attachChapterDetails(db, orgs) { + const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT c.id, c.region_id, c.meets, c.started, + r.name AS region_name, r.color AS region_color + FROM chapters c + LEFT JOIN organizations r ON r.id = c.region_id + WHERE c.id IN (${marks(ids.length)})`, + ) + .all(...ids); + + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + for (const org of orgs) { + if (org.kind !== "chapter") continue; + const row = byId[org.id] ?? {}; + org.details = { + region_id: row.region_id ?? null, + region_name: row.region_name ?? null, + region_color: row.region_color ?? null, + meets: row.meets ?? null, + started: row.started ?? null, + }; + } +} + +function attachLeadership(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + if (list) list.push(shapeLeader(row)); + else byOrg.set(row.org_id, [shapeLeader(row)]); + } + + for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; +} + +/* ── Events ──────────────────────────────────────────────────── + Flat, with the section ids alongside. Retreats.tsx owns the + section titles and colours and filters this list by section_id. + ───────────────────────────────────────────────────────────── */ + +content.get("/events", (c) => { + const db = c.get("db"); + + const sections = db + .prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`) + .all(); + + const rows = db + .prepare( + `SELECT * FROM v_events + WHERE is_published = 1 + ORDER BY section_id, sort_order`, + ) + .all(); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "event", ids); + const cards = loadBlocks(db, "event", ids, "card"); + + const events = rows.map((row) => + shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + ); + + return json(c, { sections, events }); +}); + + +/* One event, for its own page. */ +content.get("/events/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`) + .get(id); + + if (!row) return c.json({ error: "No such event" }, 404); + + const links = loadLinks(db, "event", [id]).get(id) ?? []; + const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; + + const people = db + .prepare( + `SELECT person_id, display_name, pronouns, tagline, photo, role, title + FROM v_event_people WHERE event_id = ? ORDER BY sort_order`, + ) + .all(id); + + return json(c, { + event: { ...shapeEvent(row, links, cards), blocks: body, people }, + }); +}); + + +/* ── Organizations ───────────────────────────────────────────── + GET /organizations every published org + GET /organizations?kind=region one kind + GET /organizations?kind=region,chapter + + Whatever the kind, the common card fields are in the same + places, so a list component reads `name`, `color`, `logo` and + `description` without knowing what it's holding, and reaches + into `details` only when it wants kind-specific extras. + ───────────────────────────────────────────────────────────── */ + +content.get("/organizations", (c) => { + const db = c.get("db"); + + const kindParam = c.req.query("kind"); + const kinds = kindParam + ? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k)) + : []; + + if (kindParam && kinds.length === 0) { + return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400); + } + + const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : ""; + + const rows = db + .prepare( + `SELECT * FROM organizations + WHERE is_published = 1 ${filter} + ORDER BY kind, sort_order, name`, + ) + .all(...kinds); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "organization", ids); + const cards = loadBlocks(db, "organization", ids, "card"); + const bodies = loadBlocks(db, "organization", ids, "body"); + + const organizations = rows.map((row) => + shapeOrganization( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + bodies.get(row.id) ?? [], + ), + ); + + attachRegionDetails(db, organizations); + attachChapterDetails(db, organizations); + attachLeadership(db, organizations); + + return json(c, { organizations }); +}); + + +/* One organization's page: region, chapter, partner or NGU. */ +content.get("/organizations/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`) + .get(id); + + if (!row) return c.json({ error: "No such organization" }, 404); + + const links = loadLinks(db, "organization", [id]).get(id) ?? []; + const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "organization", [id], "body").get(id) ?? []; + + const organization = shapeOrganization(row, links, cards, body); + const one = [organization]; + + attachRegionDetails(db, one); + attachChapterDetails(db, one); + attachLeadership(db, one); + + // Everything this organization is hosting or has hosted. + organization.events = db + .prepare( + `SELECT id, title, date_label, effective_status AS status, + location_label, event_logo, effective_color AS color + FROM v_events + WHERE host_org_id = ? AND is_published = 1 + ORDER BY sort_order`, + ) + .all(id); + + return json(c, { organization }); +}); + +export default content; diff --git a/server/src/routes/feedback.js b/server/src/routes/feedback.js new file mode 100644 index 0000000..6a09822 --- /dev/null +++ b/server/src/routes/feedback.js @@ -0,0 +1,130 @@ +/* ═══════════════════════════════════════════════════════════════ + FEEDBACK ROUTE — the only public write on the site + + Everything hostile that will ever reach this service arrives + here, so the defences live here rather than being sprinkled + around: + + rate limit applied where this router is mounted (5/min) + honeypot a field real users never see or fill + length caps rejected before anything touches the database + no HTML stored verbatim, escaped at render time + + The write is a single INSERT with positional parameters, so it + works on node:sqlite and better-sqlite3 alike. No transaction: + one statement is already atomic. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { createHash, randomBytes } from "node:crypto"; + +const feedback = new Hono(); + +const LIMITS = { + name: 120, + email: 254, + message: 5000, + pagePath: 200, + sectionId: 80, +}; + +const MIN_MESSAGE = 10; + +// Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx. +// Anything else falls back to 'general' rather than being rejected — +// a renamed option shouldn't lose someone's submission. +const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"]; + +/* ── IP hashing ──────────────────────────────────────────────── + Stored so repeat abuse from one source is visible during + triage, hashed so the table never holds an address. Without + IP_SALT the salt is regenerated each boot, which makes hashes + incomparable across restarts — fine for dev, set it in + /etc/ngu/api.env for production. + ───────────────────────────────────────────────────────────── */ + +const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex"); + +if (!process.env.IP_SALT) { + console.warn("IP_SALT unset — feedback ip_hash values reset on restart"); +} + +function hashIp(ip) { + if (!ip) return null; + return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32); +} + +/* ── Input cleaning ──────────────────────────────────────────── */ + +function clean(value, max) { + if (typeof value !== "string") return ""; + return value.trim().slice(0, max); +} + +// Deliberately permissive. Rejecting odd-but-valid addresses loses +// real submissions, and the field is optional anyway. +function looksLikeEmail(value) { + return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +/* ── POST /api/feedback ──────────────────────────────────────── */ + +feedback.post("/", async (c) => { + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + // Honeypot. A bot fills every input it finds; a person can't see + // this one. Return success so the bot doesn't learn anything. + if (clean(body.website, 50) !== "") { + return c.body(null, 204); + } + + const name = clean(body.name, LIMITS.name); + const email = clean(body.email, LIMITS.email); + const message = clean(body.message, LIMITS.message); + const pagePath = clean(body.pagePath, LIMITS.pagePath); + // The picker holds nav hashes ('#chapters'); the column holds ids. + const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, ""); + const feedbackType = TYPES.includes(body.feedbackType) + ? body.feedbackType + : "general"; + + const errors = {}; + if (message.length < MIN_MESSAGE) errors.message = "Please write a little more."; + if (!looksLikeEmail(email)) errors.email = "That email doesn't look right."; + + if (Object.keys(errors).length > 0) { + return c.json({ error: "Validation failed", fields: errors }, 422); + } + + const db = c.get("db"); + + const result = db + .prepare( + `INSERT INTO feedback + (feedback_type, message, name, email, page_path, section_id, + user_agent, ip_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + feedbackType, + message, + name || null, + email || null, + pagePath || null, + sectionId || null, + clean(c.req.header("user-agent"), 500) || null, + hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()), + ); + + // created_at and status come from column defaults. + console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`); + + return c.body(null, 204); +}); + +export default feedback; diff --git a/server/src/seed.js b/server/src/seed.js new file mode 100644 index 0000000..7aa11a1 --- /dev/null +++ b/server/src/seed.js @@ -0,0 +1,398 @@ +/* ═══════════════════════════════════════════════════════════════ + SEED + + Reads the two static data modules and fills the database from + them. Run once to make the move, and re-runnable after you tweak + the source files. + + cd /root/NGU-Web.v1.3-sqlite/server + DB_PATH=./dev.db node src/seed.js + + Run it from the repo, not from /srv/ngu-api — the deployed copy + has no src/data to read. + + ⚠ It clears every content table first, so anything typed + straight into the database is lost. Feedback is never touched. + + Section presentation (title, accent, background, defaultView) is + NOT imported. Retreats.jsx owns that; only the ids come across, + so section_id has something real to reference. + + Three things it deliberately does NOT do, each flagged in the + warnings at the end rather than guessed at: + + dates "March/April 2026" isn't parseable, and half-right + dates are worse than none. starts_on stays null and + the explicit status carries the upcoming/past split + exactly as it does today. + + partners the five partner events are placeholders with no + organization behind them, so host_org_id is null. + + leads "Chapter lead name" is not a person. Inventing a + people row from a placeholder string would put a + fake name on the site. + ═══════════════════════════════════════════════════════════════ */ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { openDatabase, migrate, tx } from "./db.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = process.env.DB_PATH ?? "./dev.db"; +const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; +const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; + +// The root organization. Every national retreat hangs off this, and +// it's what makes the org_logo fallback work uniformly. +const NGU = { + id: "ngu", + name: "Next Generation of Unity", + short_name: "NGU", + color: "#138ba0", + logo: "ngu-logo-white-bg.svg", +}; + +const warnings = []; +const warn = (message) => warnings.push(message); + +/* ── Load the source modules ───────────────────────────────── */ + +async function load(relative) { + const path = resolve(HERE, relative); + try { + return await import(pathToFileURL(path).href); + } catch (err) { + console.error(`\nCould not read ${path}`); + console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n"); + throw err; + } +} + +const eventsModule = await load(EVENTS_MODULE); +const chaptersModule = await load(CHAPTERS_MODULE); + +const eventsData = eventsModule.default; +const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule; + +/* ── Helpers ───────────────────────────────────────────────── */ + +const isStateCode = (code) => + Boolean(code) && code !== "CANADA" && code in STATE_NAMES; + +const opposite = (edge) => (edge === "top" ? "bottom" : "top"); + +const instagramUrl = (handle) => + `https://instagram.com/${String(handle).replace(/^@/, "")}`; + +// "Unity Village, MO" → { locality, state_code }. Anything that +// doesn't end in a real state code keeps the whole string as the +// locality, and location_label carries the original either way. +function splitPlace(label) { + if (!label) return { locality: null, state_code: null }; + + const comma = label.lastIndexOf(","); + if (comma === -1) return { locality: label.trim(), state_code: null }; + + const head = label.slice(0, comma).trim(); + const tail = label.slice(comma + 1).trim(); + + return isStateCode(tail) + ? { locality: head, state_code: tail } + : { locality: label.trim(), state_code: null }; +} + +function chapterLocation(chapter) { + const online = chapter.state === null && !chapter.city?.includes(","); + if (online || /^online$/i.test(chapter.city ?? "")) { + return { + locality: null, state_code: null, country: "US", + location_label: chapter.city ?? "Online", is_online: 1, + }; + } + + if (chapter.state === "CANADA") { + return { + locality: splitPlace(chapter.city).locality, + state_code: null, country: "CA", + location_label: chapter.city, is_online: 0, + }; + } + + const { locality } = splitPlace(chapter.city); + return { + locality, + state_code: isStateCode(chapter.state) ? chapter.state : null, + country: "US", + location_label: chapter.city, + is_online: 0, + }; +} + +function eventLocation(label) { + if (!label || /^online$/i.test(label)) { + return { + locality: null, state_code: null, country: "US", + location_label: label ?? null, is_online: label ? 1 : 0, + }; + } + const { locality, state_code } = splitPlace(label); + return { locality, state_code, country: "US", location_label: label, is_online: 0 }; +} + +/* ── Open ──────────────────────────────────────────────────── */ + +const db = await openDatabase(DB_PATH); +migrate(db, { log: () => {} }); + +const version = db.prepare("PRAGMA user_version").get().user_version; +if (version < 2) { + throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`); +} + +/* ── Statements ────────────────────────────────────────────── */ + +const ins = { + org: db.prepare(` + INSERT INTO organizations + (id, kind, name, short_name, tagline, color, logo, + venue, locality, state_code, country, location_label, is_online, + is_published, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`), + + region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`), + + regionArea: db.prepare(` + INSERT INTO region_areas (region_id, area_code, share, edge, note) + VALUES (?, ?, ?, ?, ?)`), + + chapter: db.prepare(` + INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`), + + section: db.prepare(` + INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`), + + event: db.prepare(` + INSERT INTO events + (id, section_id, host_org_id, title, theme, + date_label, status, + locality, state_code, country, location_label, is_online, + org_logo, event_logo, color, gradient, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + + block: db.prepare(` + INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text) + VALUES (?, ?, ?, ?, ?, ?)`), + + link: db.prepare(` + INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), +}; + +const addParagraph = (kind, id, slot, order, text) => { + if (!text) return; + ins.block.run(kind, id, slot, order, "paragraph", text); +}; + +/* ── Clear ───────────────────────────────────────────────────── + Children before parents. Feedback is not in this list and is + never cleared. + ───────────────────────────────────────────────────────────── */ + +const CLEAR = [ + "people_list_members", "people_lists", + "person_awards", "awards", + "event_people", "affiliations", "teams", + "person_private", "people", + "content_block_items", "content_blocks", "links", + "events", "event_sections", + "chapters", "region_areas", "regions", "organizations", +]; + +/* ── Import ────────────────────────────────────────────────── */ + +const counts = {}; +const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n); + +tx(db, () => { + for (const table of CLEAR) db.exec(`DELETE FROM ${table}`); + db.exec("DELETE FROM sqlite_sequence"); + + /* ── The root organization ───────────────────────────────── */ + + ins.org.run( + NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo, + null, null, null, "US", null, 0, 0, + ); + bump("organizations"); + + /* ── Regions ─────────────────────────────────────────────── */ + + GROUPS.forEach((group, index) => { + ins.org.run( + group.id, "region", group.name, null, null, group.color, null, + null, null, null, "US", null, 0, index, + ); + ins.region.run(group.id, group.scope, group.note ?? null); + bump("organizations"); + bump("regions"); + + // Whole areas. Split states are skipped here and handled below, + // which matters for Iowa — it appears in great-lakes.states AND + // in SPLITS, and inserting it twice would violate the key. + for (const area of group.states) { + if (SPLITS[area]) continue; + ins.regionArea.run(group.id, area, 1.0, null, null); + bump("region_areas"); + } + }); + + // Shared areas, one row per region. The old SPLITS gave the + // sliver an explicit share and left the primary implicit; both + // are explicit now, so the renderer never subtracts. + for (const [area, split] of Object.entries(SPLITS)) { + ins.regionArea.run( + split.primary, area, + Number((1 - split.share).toFixed(4)), + opposite(split.edge), + split.primaryNote ?? null, + ); + ins.regionArea.run( + split.secondary, area, split.share, split.edge, split.secondaryNote ?? null, + ); + bump("region_areas", 2); + } + + /* ── Chapters ────────────────────────────────────────────── */ + + CHAPTERS.forEach((chapter, index) => { + const place = chapterLocation(chapter); + const region = groupOf(chapter); + + if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`); + + ins.org.run( + chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null, + chapter.where ?? null, + place.locality, place.state_code, place.country, + place.location_label, place.is_online, + index, + ); + ins.chapter.run( + chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null, + ); + bump("organizations"); + bump("chapters"); + + addParagraph("organization", chapter.id, "body", 0, chapter.about); + + let order = 0; + if (chapter.link) { + ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1); + bump("links"); + } + if (chapter.contact) { + ins.link.run( + "organization", chapter.id, order++, "email", null, + chapter.contact, `mailto:${chapter.contact}`, 0, + ); + bump("links"); + } + + if (chapter.leads) { + warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`); + } + }); + + /* ── Event sections ──────────────────────────────────────── + Ids only. Titles, accents, colours, backgrounds and default + views stay in Retreats.jsx. + ─────────────────────────────────────────────────────────── */ + + eventsData.sections.forEach((section, index) => { + ins.section.run(section.id, section.title, index); + bump("event_sections"); + }); + + /* ── Events ──────────────────────────────────────────────── */ + + const regionIds = new Set(GROUPS.map((g) => g.id)); + + // National retreats belong to NGU. Regional ones name their region + // in the slug ("northwest-2026"). Partner placeholders have no + // organization yet. + function hostFor(event, sectionId) { + if (sectionId === "national") return NGU.id; + if (sectionId === "regional") { + const match = [...regionIds] + .filter((id) => event.id.startsWith(`${id}-`)) + .sort((a, b) => b.length - a.length)[0]; + if (match) return match; + warn(`Event "${event.id}" is regional but names no region — host left null.`); + return null; + } + warn(`Event "${event.id}" has no partner organization — host left null.`); + return null; + } + + for (const section of eventsData.sections) { + section.events.forEach((event, index) => { + const place = eventLocation(event.location); + + ins.event.run( + event.id, section.id, hostFor(event, section.id), + event.title, event.theme ?? null, + event.date ?? null, event.status ?? null, + place.locality, place.state_code, place.country, + place.location_label, place.is_online, + event.org_logo ?? null, event.image ?? null, + event.color ?? null, event.gradient ?? null, + index, + ); + bump("events"); + + // desc_a and desc_b become the card slot, in order. The body + // slot is left empty for the full page you'll write later. + addParagraph("event", event.id, "card", 0, event.desc_a); + addParagraph("event", event.id, "card", 1, event.desc_b); + + let order = 0; + (event.links ?? []).forEach((link, i) => { + if (!/^https?:\/\//.test(link.link)) { + warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`); + } + ins.link.run( + "event", event.id, order++, "action", null, + link.label, link.link, i === 0 ? 1 : 0, + ); + bump("links"); + }); + + if (event.instagram) { + ins.link.run( + "event", event.id, order++, "social", "instagram", + event.instagram, instagramUrl(event.instagram), 0, + ); + bump("links"); + } + }); + } +}); + +db.close(); + +/* ── Report ────────────────────────────────────────────────── */ + +console.log(`\nSeeded ${DB_PATH}\n`); +for (const [table, n] of Object.entries(counts).sort()) { + console.log(` ${String(n).padStart(4)} ${table}`); +} + +if (warnings.length > 0) { + console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`); + for (const message of warnings) console.log(` · ${message}`); +} + +console.log(""); diff --git a/server/src/shape.js b/server/src/shape.js new file mode 100644 index 0000000..797db14 --- /dev/null +++ b/server/src/shape.js @@ -0,0 +1,147 @@ +/* ═══════════════════════════════════════════════════════════════ + SHAPE + + Blocks and links are polymorphic: any organization, event, person + or team can own them. Every list endpoint therefore needs the + same move — fetch the parent rows, then fetch all their children + in one query each and stitch. + + The alternative is a query per row, which at a dozen events is + invisible and at two hundred is not. Three queries is three + queries whatever the row count, so it may as well be right now. + + One limit worth knowing: SQLite caps bound parameters per + statement (999 on older builds). If a list ever exceeds that, + these need chunking. Nothing here comes close. + ═══════════════════════════════════════════════════════════════ */ + +const placeholders = (n) => Array(n).fill("?").join(","); + +const asBool = (value) => value === 1; + +/* ── Links ───────────────────────────────────────────────────── + Map of owner_id → links, in sort order. + ───────────────────────────────────────────────────────────── */ + +export function loadLinks(db, ownerKind, ids) { + const out = new Map(); + if (ids.length === 0) return out; + + const rows = db + .prepare( + `SELECT owner_id, kind, platform, label, url, is_primary + FROM links + WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)}) + ORDER BY owner_id, sort_order`, + ) + .all(ownerKind, ...ids); + + for (const row of rows) { + const link = { + kind: row.kind, + platform: row.platform, + label: row.label, + url: row.url, + is_primary: asBool(row.is_primary), + }; + const list = out.get(row.owner_id); + if (list) list.push(link); + else out.set(row.owner_id, [link]); + } + + return out; +} + +/* ── Blocks ──────────────────────────────────────────────────── + Map of owner_id → blocks for one slot, items attached. + + Two queries: the blocks, then every item belonging to them. + Blocks with no children come back with an empty items array + rather than no key, so the renderer never has to check. + ───────────────────────────────────────────────────────────── */ + +export function loadBlocks(db, ownerKind, ids, slot = "body") { + const out = new Map(); + if (ids.length === 0) return out; + + const blockRows = db + .prepare( + `SELECT id, owner_id, type, text, media, href + FROM content_blocks + WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)}) + ORDER BY owner_id, sort_order`, + ) + .all(ownerKind, slot, ...ids); + + if (blockRows.length === 0) return out; + + const byId = new Map(); + + for (const row of blockRows) { + const block = { + type: row.type, + text: row.text, + media: row.media, + href: row.href, + items: [], + }; + byId.set(row.id, block); + + const list = out.get(row.owner_id); + if (list) list.push(block); + else out.set(row.owner_id, [block]); + } + + const blockIds = [...byId.keys()]; + + const itemRows = db + .prepare( + `SELECT block_id, text, detail, url + FROM content_block_items + WHERE block_id IN (${placeholders(blockIds.length)}) + ORDER BY block_id, sort_order`, + ) + .all(...blockIds); + + for (const row of itemRows) { + byId.get(row.block_id)?.items.push({ + text: row.text, + detail: row.detail, + url: row.url, + }); + } + + return out; +} + +/* ── Card description ────────────────────────────────────────── + The card slot is paragraphs by convention, so it collapses to + an array of strings — desc_a and desc_b become description[0] + and description[1]. A non-paragraph block in the card slot is + ignored here; put it in the body slot instead. + ───────────────────────────────────────────────────────────── */ + +export function paragraphs(blocks = []) { + return blocks + .filter((block) => block.type === "paragraph" && block.text) + .map((block) => block.text); +} + +/* ── Split entity links ──────────────────────────────────────── + Socials are lifted out of the list because cards treat them + differently: Instagram is an icon, Register is a button. The + underlying rows are the same table. + ───────────────────────────────────────────────────────────── */ + +export function splitLinks(links = []) { + return { + actions: links.filter((link) => link.kind === "action"), + socials: links.filter((link) => link.kind === "social"), + website: links.find((link) => link.kind === "website")?.url ?? null, + email: links.find((link) => link.kind === "email")?.label ?? null, + instagram: + links.find((link) => link.platform === "instagram")?.label ?? null, + }; +} + +export { asBool }; diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx new file mode 100644 index 0000000..b2f9af0 --- /dev/null +++ b/src/components/ArrowLink.tsx @@ -0,0 +1,30 @@ +import { Link } from "react-router-dom"; + +/* ═══════════════════════════════════════════════════════════════ + ARROW LINK + ═══════════════════════════════════════════════════════════════ */ + +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { + return ( + + + + ); +} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index b723353..7c0f6cc 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { SITE_BANNER } from "../bannerConfig.js"; +import { SITE_BANNER } from "../data/bannerConfig.js"; export default function Banner() { const [visible, setVisible] = useState(false); diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css new file mode 100644 index 0000000..2f70d5d --- /dev/null +++ b/src/components/PeopleTiles.css @@ -0,0 +1,365 @@ +/* PeopleTiles — polaroid-style people list. + Everything is scoped under .pl and driven by custom properties, so a section + can retune it inline: */ + +.pl { + /* geometry */ + --pl-scale: 1; + --pl-base-w: 132px; + --pl-w: calc(var(--pl-base-w) * var(--pl-scale)); + --pl-gap: calc(0.875rem * var(--pl-scale)); + --pl-pad: calc(0.5rem * var(--pl-scale)); + --pl-photo-ratio: 1; + --pl-radius: 3px; + + /* color */ + --pl-accent: #138ba0; + --pl-frame-bg: #fff; + --pl-frame-edge: rgba(15, 23, 42, 0.1); + --pl-photo-bg: #e7edef; + --pl-ink: #16262b; + --pl-muted: #4a6b72; + --pl-rule: rgba(45, 200, 224, 0.3); + --pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5); + + container-type: inline-size; + display: flex; + flex-direction: column; + gap: calc(var(--pl-gap) * 1.25); + color: var(--pl-ink); +} + +.pl[data-size="sm"] { + --pl-base-w: 104px; + --pl-radius: 2px; +} + +.pl[data-size="lg"] { + --pl-base-w: 164px; + --pl-photo-ratio: 4 / 5; +} + +/* groups --------------------------------------------------------------- */ + +.pl__groups { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: calc(var(--pl-gap) * 1.5); +} + +.pl[data-align="center"] .pl__groups, +.pl[data-align="center"] .pl__row { + justify-content: center; +} + +.pl__group { + display: flex; + flex-direction: column; + gap: calc(var(--pl-gap) * 0.75); + min-width: 0; +} + +.pl__group + .pl__group { + padding-left: calc(var(--pl-gap) * 1.5); + border-left: 1px solid var(--pl-rule); +} + +.pl__group-head { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.pl__group-label { + margin: 0; + font-size: calc(0.9375rem * var(--pl-scale)); + font-weight: 600; + letter-spacing: 0.01em; +} + +.pl__group-note { + margin: 0; + font-size: calc(0.8125rem * var(--pl-scale)); + color: var(--pl-muted); +} + +.pl__row { + display: flex; + flex-wrap: wrap; + gap: var(--pl-gap); + margin: 0; + padding: 0; + list-style: none; +} + +.pl[data-overflow="scroll"] .pl__row { + flex-wrap: nowrap; + overflow-x: auto; + scroll-snap-type: x proximity; + padding-bottom: 0.35rem; + scrollbar-width: thin; +} + +.pl[data-overflow="scroll"] .pl__item { + scroll-snap-align: start; +} + +.pl__item { + flex: 0 0 auto; +} + +/* tile ----------------------------------------------------------------- */ + +.pl__tile { + display: block; + width: var(--pl-w); + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: inherit; +} + +.pl__tile--button { + cursor: pointer; +} + +.pl__frame { + position: relative; + display: flex; + flex-direction: column; + gap: calc(var(--pl-pad) * 0.9); + padding: var(--pl-pad); + padding-bottom: calc(var(--pl-pad) * 1.6); + background: var(--pl-frame-bg); + border: 1px solid var(--pl-frame-edge); + border-radius: var(--pl-radius); + box-shadow: var(--pl-shadow); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame { + transform: rotate(-1.1deg); +} + +.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame { + transform: rotate(0.9deg); +} + +.pl__tile--button:hover .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 { + outline: none; +} + +.pl__tile--button:focus-visible .pl__frame { + outline: 2px solid var(--pl-accent); + outline-offset: 3px; +} + +.pl__tile[aria-expanded="true"] .pl__frame { + border-color: var(--pl-accent); + box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6); +} + +.pl__photo { + display: grid; + place-items: center; + aspect-ratio: var(--pl-photo-ratio); + overflow: hidden; + background: var(--pl-photo-bg); + border-radius: 1px; +} + +.pl__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pl__initials { + font-size: calc(1.25rem * var(--pl-scale)); + font-weight: 600; + letter-spacing: 0.03em; + color: var(--pl-muted); +} + +.pl__caption { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0 calc(var(--pl-pad) * 0.25); + min-height: calc(1.1rem * var(--pl-scale)); +} + +.pl__name { + font-size: calc(0.875rem * var(--pl-scale)); + font-weight: 600; + line-height: 1.25; +} + +.pl__title { + font-size: calc(0.75rem * var(--pl-scale)); + line-height: 1.3; + color: var(--pl-muted); +} + +.pl__badge { + position: absolute; + right: calc(var(--pl-pad) * 0.6); + bottom: calc(var(--pl-pad) * 0.6); + display: grid; + place-items: center; + width: calc(1.25rem * var(--pl-scale)); + height: calc(1.25rem * var(--pl-scale)); + border-radius: 999px; + background: var(--pl-accent); + color: #fff; + transition: transform 160ms ease; +} + +.pl__tile[aria-expanded="true"] .pl__badge { + transform: rotate(180deg); +} + +/* bio panel ------------------------------------------------------------ */ + +.pl__bio { + display: flex; + flex-direction: column; + gap: 0.65rem; + padding: 1rem 1.1rem; + border-left: 3px solid var(--pl-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--pl-accent) 7%, #fff); + max-width: 68ch; +} + +.pl__bio-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.pl__bio-name { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.pl__bio-title { + margin: 0.1rem 0 0; + font-size: 0.875rem; + color: var(--pl-muted); +} + +.pl__close { + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--pl-frame-edge); + border-radius: 999px; + background: #fff; + color: var(--pl-muted); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} + +.pl__close:hover { + color: var(--pl-ink); + border-color: var(--pl-accent); +} + +.pl__facts { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.5rem; + margin: 0; +} + +.pl__fact dt { + font-size: 0.6875rem; + font-weight: 600; + color: var(--pl-muted); +} + +.pl__fact dd { + margin: 0; + font-size: 0.875rem; +} + +.pl__fact a { + color: var(--pl-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.pl__bio-text { + margin: 0; + font-size: 0.9375rem; + line-height: 1.6; + max-width: 62ch; +} + +.pl__empty { + margin: 0; + font-size: 0.9375rem; + color: var(--pl-muted); +} + +.pl__sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* narrow layouts ------------------------------------------------------- */ + +@container (max-width: 640px) { + .pl__group + .pl__group { + padding-left: 0; + padding-top: calc(var(--pl-gap) * 1.1); + border-left: 0; + border-top: 1px solid var(--pl-rule); + width: 100%; + } +} + +@supports not (container-type: inline-size) { + @media (max-width: 640px) { + .pl__group + .pl__group { + padding-left: 0; + padding-top: calc(var(--pl-gap) * 1.1); + border-left: 0; + border-top: 1px solid var(--pl-rule); + width: 100%; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .pl__frame, + .pl__badge { + transition: none; + } + + .pl__tile--button:hover .pl__frame, + .pl__tile--button:focus-visible .pl__frame { + transform: none; + } +} diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx new file mode 100644 index 0000000..e613b55 --- /dev/null +++ b/src/components/PeopleTiles.tsx @@ -0,0 +1,375 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import "./PeopleTiles.css"; + +/** + * PeopleTiles — a horizontal, polaroid-style people list. + * + * Drop into any section: + * + * + * + * Sizes + * sm photo + name + * md photo + name + title + * lg photo + name + title + expandable bio (pronouns, age, primary org above the bio) + * + * Group shape + * { id, label?, note?, accent?, people: [person] } + * + * Person shape + * { + * id, name, + * title?, // "Regional Director" + * photo?, // "/people/jane-doe.jpg" — falls back to initials + * pronouns?, // "she/her" + * age?, // number, or use birthdate + * birthdate?, // "1998-04-12" — age is derived when `age` is absent + * org?, // "Grace Chapel" or { name, href } + * bio?, // string or string[] (paragraphs) + * accent?, // per-person override + * } + */ + +const SIZE_FEATURES = { + sm: { title: false, bio: false }, + md: { title: true, bio: false }, + lg: { title: true, bio: true }, +}; + +export default function PeopleTiles({ + people, + groups, + size = "md", + scale = 1, + overflow = "wrap", // "wrap" | "scroll" + align = "start", // "start" | "center" + accent, + tilt = false, + emptyMessage = "No one listed yet.", + onExpand, + className = "", + style, + ...rest +}) { + const baseId = useId().replace(/:/g, ""); + const [openKey, setOpenKey] = useState(null); + const rootRef = useRef(null); + + const resolvedSize = SIZE_FEATURES[size] ? size : "md"; + const features = SIZE_FEATURES[resolvedSize]; + + const resolvedGroups = useMemo(() => { + const source = Array.isArray(groups) && groups.length + ? groups + : Array.isArray(people) && people.length + ? [{ id: "all", people }] + : []; + + return source + .map((group, groupIndex) => ({ + ...group, + id: group.id ?? `group-${groupIndex}`, + people: (group.people || []).filter(Boolean), + })) + .filter((group) => group.people.length > 0); + }, [groups, people]); + + // Close the bio if the person it belongs to disappears from the data. + useEffect(() => { + if (!openKey) return; + const stillThere = resolvedGroups.some((group) => + group.people.some((person, index) => keyFor(group, person, index) === openKey), + ); + if (!stillThere) setOpenKey(null); + }, [openKey, resolvedGroups]); + + if (!resolvedGroups.length) { + return emptyMessage ?

{emptyMessage}

: null; + } + + const open = features.bio ? findByKey(resolvedGroups, openKey) : null; + + function toggle(group, person, index) { + const key = keyFor(group, person, index); + const next = openKey === key ? null : key; + setOpenKey(next); + if (onExpand) onExpand(next ? person : null, next ? group : null); + } + + function handleKeyDown(event) { + if (event.key === "Escape" && openKey) { + event.stopPropagation(); + setOpenKey(null); + const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]'); + if (button) button.focus(); + } + } + + return ( +
+
+ {resolvedGroups.map((group) => ( +
+ {(group.label || group.note) && ( +
+ {group.label &&

{group.label}

} + {group.note &&

{group.note}

} +
+ )} + +
    + {group.people.map((person, index) => { + const key = keyFor(group, person, index); + const expandable = features.bio && hasBio(person); + const isOpen = expandable && openKey === key; + + return ( +
  • + toggle(group, person, index)} + /> +
  • + ); + })} +
+
+ ))} +
+ + {open && ( + { + setOpenKey(null); + if (onExpand) onExpand(null, null); + }} + /> + )} +
+ ); +} + +function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) { + const content = ( + <> + + + + + + {person.name} + {showTitle && person.title && {person.title}} + + {expandable && ( + + )} + + + ); + + if (!expandable) { + return
{content}
; + } + + return ( + + ); +} + +function Photo({ src, name }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return ( + + ); + } + + return ( + setFailed(true)} + /> + ); +} + +function BioPanel({ id, person, group, onClose }) { + const age = resolveAge(person); + const org = resolveOrg(person.org); + const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; + + return ( +
+
+
+

{person.name}

+ {person.title &&

{person.title}

} +
+ +
+ + {(person.pronouns || age != null || org) && ( +
+ {person.pronouns && ( +
+
Pronouns
+
{person.pronouns}
+
+ )} + {age != null && ( +
+
Age
+
{age}
+
+ )} + {org && ( +
+
Home organization
+
+ {org.href ? ( + + {org.name} + + ) : ( + org.name + )} +
+
+ )} +
+ )} + + {paragraphs.filter(Boolean).map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function Chevron() { + return ( + + + + ); +} + +/* helpers ------------------------------------------------------------- */ + +function keyFor(group, person, index) { + return `${group.id}:${person.id ?? person.name ?? index}`; +} + +function findByKey(groups, key) { + if (!key) return null; + for (const group of groups) { + for (let index = 0; index < group.people.length; index += 1) { + const person = group.people[index]; + if (keyFor(group, person, index) === key) return { group, person }; + } + } + return null; +} + +function hasBio(person) { + if (Array.isArray(person.bio)) return person.bio.some(Boolean); + return Boolean(person.bio); +} + +function initials(name = "") { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0] || "") + .join("") + .toUpperCase(); +} + +function resolveAge(person) { + if (typeof person.age === "number") return person.age; + if (!person.birthdate) return null; + const born = new Date(person.birthdate); + if (Number.isNaN(born.getTime())) return null; + const now = new Date(); + let age = now.getFullYear() - born.getFullYear(); + const monthDelta = now.getMonth() - born.getMonth(); + if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1; + return age >= 0 ? age : null; +} + +function resolveOrg(org) { + if (!org) return null; + if (typeof org === "string") return { name: org }; + if (!org.name) return null; + return org; +} diff --git a/src/bannerConfig.js b/src/data/bannerConfig.js similarity index 100% rename from src/bannerConfig.js rename to src/data/bannerConfig.js diff --git a/src/data/chapters.js b/src/data/chapters.js new file mode 100644 index 0000000..5ee7e37 --- /dev/null +++ b/src/data/chapters.js @@ -0,0 +1,146 @@ +/* ═══════════════════════════════════════════════════════════════ + LOCAL CHAPTER DATA + + The chapter map's view of the organization list. Regions and + chapters are the same table and the same endpoint; this hook + reshapes them into what a map needs — slices per tile, counts + per tile, chapters grouped by region. + + Its return shape is unchanged from the version that called + /regions and /chapters, so LocalChapters didn't have to move. + + Two things live elsewhere: + + the tile grid src/data/mapGrid.js. Where a state sits never + changes, so it isn't worth a round trip. + + the fetch src/data/organizations.js. One endpoint for + every kind, so a section listing regions and + a section listing partners read alike. + ═══════════════════════════════════════════════════════════════ */ + +import { useMemo } from "react"; + +import { + areasSentence, + initialsFor, + useOrganizations, +} from "./organizations.js"; +import { + areaForChapter, + buildAreaSlices, + countChaptersByArea, +} from "./mapGrid.js"; + +const byName = (a, b) => a.name.localeCompare(b.name); + +export { initialsFor }; + +export function useCommunity() { + const regionsQuery = useOrganizations("region"); + const chaptersQuery = useOrganizations("chapter"); + + const rawRegions = regionsQuery.organizations; + const rawChapters = chaptersQuery.organizations; + + return useMemo(() => { + /* ── Regions ─────────────────────────────────────────────── + scope and map_note are lifted out of `details` so the rest + of the app doesn't have to know they're kind-specific. */ + const regions = rawRegions.map((org) => ({ + ...org, + scope: org.details?.scope ?? null, + map_note: org.details?.map_note ?? null, + areas: org.details?.areas ?? [], + })); + + // Flat, because buildAreaSlices takes it that way. + const regionAreas = regions.flatMap((region) => + region.areas.map((area) => ({ ...area, region_id: region.id })), + ); + + /* ── Chapters, each tagged with the tile it lights up ────── + The database stores a real address; which square that maps + to is a rendering question, answered here once rather than + at every call site. */ + const chapters = rawChapters.map((org) => ({ + ...org, + region_id: org.details?.region_id ?? null, + region_name: org.details?.region_name ?? null, + region_color: org.details?.region_color ?? null, + meets: org.details?.meets ?? null, + started: org.details?.started ?? null, + area_code: areaForChapter(org), + })); + + const regionById = Object.fromEntries(regions.map((r) => [r.id, r])); + + /* { WA: [{ regionId, name, color, share, edge, note }], ... } + California comes back as two half slices rather than a + primary and a remainder, so the SVG never does arithmetic. */ + const slices = buildAreaSlices(regionAreas, regions); + + const chapterCounts = countChaptersByArea(chapters); + + const chaptersByRegion = new Map(); + for (const chapter of chapters) { + if (!chapter.region_id) continue; + const list = chaptersByRegion.get(chapter.region_id); + if (list) list.push(chapter); + else chaptersByRegion.set(chapter.region_id, [chapter]); + } + + const chaptersIn = (regionId) => chaptersByRegion.get(regionId) ?? []; + + /* Sorted by name to match how the page has always shown them, + rather than by the sort_order the API returns. */ + const domestic = regions.filter((r) => r.scope === "domestic").sort(byName); + const international = regions + .filter((r) => r.scope === "international") + .sort(byName); + const virtual = regions.filter((r) => r.scope === "virtual"); + + const areasLabelFor = (regionId) => + areasSentence(regionById[regionId]?.areas ?? []); + + const subtextFor = (region) => { + const label = areasLabelFor(region.id); + if (label && region.map_note) return `${label} · ${region.map_note}`; + return label || region.map_note || ""; + }; + + const regionsForArea = (areaCode) => + (slices[areaCode] ?? []) + .map((slice) => regionById[slice.regionId]) + .filter(Boolean); + + return { + loading: regionsQuery.loading || chaptersQuery.loading, + error: regionsQuery.error ?? chaptersQuery.error, + + regions, + regionAreas, + regionById, + domestic, + international, + virtual, + + chapters, + chaptersIn, + + slices, + chapterCounts, + regionsForArea, + + areasLabelFor, + subtextFor, + }; + }, [ + rawRegions, + rawChapters, + regionsQuery.loading, + regionsQuery.error, + chaptersQuery.loading, + chaptersQuery.error, + ]); +} diff --git a/src/data/eventData.js b/src/data/eventData.js new file mode 100644 index 0000000..ee2364e --- /dev/null +++ b/src/data/eventData.js @@ -0,0 +1,54 @@ +/* ═══════════════════════════════════════════════════════════════ + EVENT DATA + + One request, filtered per section. The Retreats page has three + bands of events, and all three call this hook — the cache in + api.js keys on the path, so they share a single fetch and each + narrows the result to what it shows. + + useEvents({ section: "national" }) one band + useEvents({ host: "northwest" }) a region's own events + useEvents({ status: "upcoming" }) a home page strip + useEvents() everything + + 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 + the URL and let each become its own cache entry. + ═══════════════════════════════════════════════════════════════ */ + +import { useMemo } from "react"; + +import { useResource } from "../lib/useResource.js"; + +const EMPTY = { events: [] }; + +export function useEvents({ section, host, status } = {}) { + const { data, error, loading } = useResource("/events", { fallback: EMPTY }); + + const all = data?.events; + + const events = useMemo(() => { + let list = all ?? []; + if (section) list = list.filter(e => e.section_id === section); + if (host) list = list.filter(e => e.host?.id === host); + if (status) list = list.filter(e => e.status === status); + return list; + }, [all, section, host, status]); + + return { events, loading, error }; +} + +/* Past and upcoming, split. `status` arrives already resolved — the + explicit value when there is one, otherwise derived from ends_on + — so nothing here needs to know which of the two it got. */ +export function splitByStatus(events = []) { + const upcoming = []; + const past = []; + + for (const event of events) { + (event.status === "past" ? past : upcoming).push(event); + } + + return { upcoming, past }; +} diff --git a/src/events.js b/src/data/events.js similarity index 98% rename from src/events.js rename to src/data/events.js index 06a49ce..6e25574 100644 --- a/src/events.js +++ b/src/data/events.js @@ -185,7 +185,7 @@ const eventsData = { background: "#eef9fb", events: [ { - id: "partner-example", + id: "partner-example-1", title: "Partner Event Name", theme: null, date: "Date", @@ -206,7 +206,7 @@ const eventsData = { ] }, { - id: "partner-example", + id: "partner-example-2", title: "Partner Event Name", theme: null, date: "Date", @@ -227,7 +227,7 @@ const eventsData = { ] }, { - id: "partner-example", + id: "partner-example-3", title: "Partner Event Name", theme: null, date: "Date", @@ -248,7 +248,7 @@ const eventsData = { ] }, { - id: "partner-example", + id: "partner-example-4", title: "Partner Event Name", theme: null, date: "Date", @@ -269,7 +269,7 @@ const eventsData = { ] }, { - id: "partner-example", + id: "partner-example-5", title: "Partner Event Name", theme: null, date: "Date", diff --git a/src/data/mapGrid.js b/src/data/mapGrid.js new file mode 100644 index 0000000..ede40cf --- /dev/null +++ b/src/data/mapGrid.js @@ -0,0 +1,174 @@ +/* ═══════════════════════════════════════════════════════════════ + MAP GRID + + Pure layout. Where each tile sits, what it's called, and how to + work out which tile a chapter belongs to. None of this is in the + database because none of it changes — Rhode Island will not be + moving, and no admin form should offer to move it. + + What IS in the database is which regions cover which areas, and + how much of each. That arrives as region_areas rows whose + area_code matches a key in AREAS below. A code with no match + here simply doesn't paint, which is how Africa and the UK exist + as regions with no tile. + ═══════════════════════════════════════════════════════════════ */ + +export const GRID_COLS = 13; +export const GRID_ROWS = 7; + +/* ── States ──────────────────────────────────────────────────── + [column, row], both 1-based. A tile grid rather than true + geography: every state reads at the same size, it stays + legible on a phone, and there's no map library to load. + ───────────────────────────────────────────────────────────── */ +const STATE_GRID = { + AK: [1, 1], ME: [13, 1], + WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2], + MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2], + OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3], + IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3], + CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5], + WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4], + AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6], + DC: [9, 5], RI: [12, 3], + OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6], + HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7], +}; + +/* ── Bands ───────────────────────────────────────────────────── + Wide areas that aren't states. A band is just a tile with a + span, which keeps the renderer from needing a second code path. + ───────────────────────────────────────────────────────────── */ +const BAND_GRID = { + CANADA: [3, 1, 8], // col, row, span +}; + +export const AREA_NAMES = { + AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", + CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware", + DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i", + ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas", + KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland", + MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi", + MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada", + NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York", + NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", + OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island", + SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas", + UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington", + WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming", + CANADA: "Canada", +}; + +/* ── One list the renderer walks ─────────────────────────────── + States and bands unified, so drawing the map is a single map() + over AREAS rather than two loops with different shapes. + ───────────────────────────────────────────────────────────── */ +export const AREAS = Object.freeze([ + ...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({ + code, + name: AREA_NAMES[code] ?? code, + col, + row, + span: 1, + isState: true, + })), + ...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({ + code, + name: AREA_NAMES[code] ?? code, + col, + row, + span, + isState: false, + })), +]); + +export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a])); + +/* ── Which tile a chapter sits on ────────────────────────────── + The database stores a real address — state_code for US, and a + country code otherwise — rather than a tile name. This is the + one place that translates between the two, so adding a Mexico + band later means a line here and nothing in SQL. + + Returns null for anything with no tile, which covers virtual + chapters and any country not drawn. + ───────────────────────────────────────────────────────────── */ +const COUNTRY_AREA = { + CA: "CANADA", // ISO country code, not California +}; + +export function areaForChapter(chapter) { + if (!chapter) return null; + if (chapter.is_online) return null; + + if (chapter.country === "US") { + return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null; + } + return COUNTRY_AREA[chapter.country] ?? null; +} + +/* ── Slices per tile ─────────────────────────────────────────── + Turns region_areas rows into what the SVG needs: for each + tile, the regions painting it and the fraction each takes. + + A region with share 1 and no edge fills the tile. A shared + tile has one row per region, each declaring its own slice, so + California is two entries of 0.5 rather than a primary plus a + remainder — no arithmetic, and the renderer doesn't need to + know which region "really" owns it. + + regionAreas [{ region_id, area_code, share, edge, note }] + regions [{ id, name, color, ... }] + ───────────────────────────────────────────────────────────── */ +export function buildAreaSlices(regionAreas = [], regions = []) { + const regionById = Object.fromEntries(regions.map((r) => [r.id, r])); + const byArea = {}; + + for (const row of regionAreas) { + const area = AREA_BY_CODE[row.area_code]; + const region = regionById[row.region_id]; + if (!area || !region) continue; // untiled region, or unknown code + + (byArea[row.area_code] ??= []).push({ + regionId: region.id, + name: region.name, + color: region.color, + share: row.share ?? 1, + edge: row.edge ?? null, + note: row.note ?? null, + }); + } + + // Full-tile slice first, so a partial slice paints over it. + for (const slices of Object.values(byArea)) { + slices.sort((a, b) => b.share - a.share); + } + + return byArea; +} + +/* ── Chapter counts per tile ─────────────────────────────────── + { WA: 2, MO: 1, CANADA: 1 } + ───────────────────────────────────────────────────────────── */ +export function countChaptersByArea(chapters = []) { + const counts = {}; + for (const chapter of chapters) { + const code = areaForChapter(chapter); + if (code) counts[code] = (counts[code] ?? 0) + 1; + } + return counts; +} + +/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ──────────── + Replaces statesLabel. Note the annotation now comes straight + from the row rather than being looked up in a splits table and + branched on whether this region is the primary. + ───────────────────────────────────────────────────────────── */ +export function areasLabel(regionId, regionAreas = []) { + return regionAreas + .filter((row) => row.region_id === regionId && row.area_code !== "CANADA") + .map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code)) + .sort() + .join(", "); +} diff --git a/src/data/organizations.js b/src/data/organizations.js new file mode 100644 index 0000000..50a5405 --- /dev/null +++ b/src/data/organizations.js @@ -0,0 +1,90 @@ +/* ═══════════════════════════════════════════════════════════════ + ORGANIZATIONS + + Regions, chapters, partners and NGU itself are one table and one + endpoint. Anything that lists organizations reads them the same + way and filters by kind: + + const { organizations } = useOrganizations("region"); + const { organizations } = useOrganizations("partner"); + const { organizations } = useOrganizations(); // all + + Every organization has the same card surface — name, colour, + logo, description, links. What differs by kind sits under + `details`, so a list component can render the common parts + without knowing what it's holding: + + region { scope, map_note, areas[], chapters[] } + chapter { region_id, region_name, region_color, meets, started } + partner {} + + Each kind is a separate request path, so the cache in api.js + keys them apart and two sections asking for regions share one + fetch. + ═══════════════════════════════════════════════════════════════ */ + +import { useResource } from "../lib/useResource.js"; + +const EMPTY = { organizations: [] }; + +export function useOrganizations(kind) { + const path = kind + ? `/organizations?kind=${encodeURIComponent(kind)}` + : "/organizations"; + + const { data, error, loading } = useResource(path, { fallback: EMPTY }); + + return { + organizations: data?.organizations ?? [], + loading, + error, + }; +} + +/* ── Where an organization's page lives ──────────────────────── + One place to change when routes move. Kinds with no page of + their own return null, and a list should render no link rather + than a dead one. + ───────────────────────────────────────────────────────────── */ + +const PATHS = { + region: "/regions", + chapter: "/chapters", + partner: "/partners", +}; + +export function orgPath(org) { + const base = PATHS[org?.kind]; + return base ? `${base}/${org.id}` : null; +} + +/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ──────────── + The areas a region covers, annotated where it holds only part + of one. Canada is a band on the map rather than somewhere you'd + list, so it's left out of the sentence. + + The note comes straight off the row. The old statesLabel had to + work out whether this region was the primary or the secondary of + a split before it knew which note applied; there's no such thing + any more. + ───────────────────────────────────────────────────────────── */ + +export function areasSentence(areas = []) { + return areas + .filter((area) => area.area_code !== "CANADA") + .map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code)) + .sort() + .join(", "); +} + +/* Initials for a card with no logo. "NGU Lynnwood" → "NL", dropping + the org prefix so every card doesn't read "NG". */ +export function initialsFor(name = "") { + return name + .replace(/^NGU\s+/i, "") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0].toUpperCase()) + .join(""); +} diff --git a/src/index.css b/src/index.css index 6c6f330..78f3533 100644 --- a/src/index.css +++ b/src/index.css @@ -69,3 +69,143 @@ h1, h2, h3, h4, h5, h6 { .footer-icons:hover .ig-icon { fill: var(--ig-fill); } .fb-icon:hover { fill: #0862f7; } .ds-icon:hover { fill: #5865f1; } + + +/* Event Card Section */ +.ev-card { + container-type: inline-size; +} + +.ev-grid { + display: grid; + gap: 0.25rem 1.5rem; + grid-template-columns: 2fr 1fr; + grid-template-areas: + "ngu image" + "info image" + "desc desc"; +} + +.ev-ngu { + grid-area: ngu; +} + +.ev-info { + grid-area: info; +} + +.ev-image { + grid-area: image; + justify-self: stretch; + align-self: center; + max-height: 11rem; +} + +.ev-desc { + grid-area: desc; + margin-top: 1rem; +} + +@container (max-width: 30rem) { + .ev-grid { + grid-template-columns: 1fr; + grid-template-areas: + "ngu" + "info" + "desc"; + } + + .ev-grid .ev-image { + justify-self: start; + width: auto; + max-height: 7rem; + margin-bottom: 0.5rem; + } +} + +/* ═══════════════════════════════════════════════════════════════ + SKELETON + Holds roughly a card's height so the page doesn't jump when the + data arrives. The shimmer is decoration, so it goes away for + anyone who has asked for less motion. + ═══════════════════════════════════════════════════════════════ */ + +.skeleton { + border-radius: 1.5rem; + min-height: 22rem; + background: linear-gradient( + 100deg, + rgba(0, 0, 0, 0.04) 30%, + rgba(0, 0, 0, 0.08) 50%, + rgba(0, 0, 0, 0.04) 70% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s ease-in-out infinite; +} + +@keyframes skeleton-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} + +/* ═══════════════════════════════════════════════════════════════ + UNITY REGIONS + Append to index.css. Scoped to .ur- so nothing here reaches the + rest of the site. + ═══════════════════════════════════════════════════════════════ */ + +/* The 0fr → 1fr grid row animates to the panel's real height, so + nobody has to guess a max-height that's wrong the moment a + region's description grows. The inline style sets the value; + this sets how it moves. */ +.ur-panel { + display: grid; + transition: grid-template-rows 300ms ease; +} + +.ur-chevron { + transition: transform 200ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .ur-panel, + .ur-chevron { + transition: none; + } +} + +/* ═══════════════════════════════════════════════════════════════ + ORGANIZATION LIST + Append to index.css. Scoped to .ol- so nothing here reaches the + rest of the site. + ═══════════════════════════════════════════════════════════════ */ + +/* The 0fr → 1fr grid row animates to the panel's real height, so + nobody has to guess a max-height that's wrong the moment a + description grows. The inline style sets the value; this sets + how it moves. */ +.ol-panel { + display: grid; + transition: grid-template-rows 300ms ease; +} + +.ol-chevron { + transition: transform 200ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .ol-panel, + .ol-chevron { + transition: none; + } +} diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..091a4b7 --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,102 @@ +/* ═══════════════════════════════════════════════════════════════ + API CLIENT + + One place that knows how to talk to the server, so components + never call fetch directly and swapping the transport later is a + single-file change. + + Two things worth knowing about the design: + + The cache is a module-level Map of in-flight and settled + promises. Two components asking for /events during the same + render pass share one request, and a remount inside the TTL + costs nothing. It resets on page load, which is the right + lifetime for content that changes weekly. + + Every reader can pass a `fallback`. If the request fails, that + value is used instead. This is what keeps the Figma preview + working: pass the old static module as the fallback and the + preview renders real content with no server in sight. + ═══════════════════════════════════════════════════════════════ */ + +const BASE = import.meta.env?.VITE_API_BASE ?? "/api"; +const DEFAULT_TTL = 60_000; + +const cache = new Map(); // path → { at, promise } + +export class ApiError extends Error { + constructor(message, { status, fields } = {}) { + super(message); + this.name = "ApiError"; + this.status = status; + this.fields = fields; + } +} + +async function request(path, options = {}) { + const response = await fetch(`${BASE}${path}`, { + headers: { Accept: "application/json", ...options.headers }, + ...options, + }); + + if (response.status === 204) return null; + + const type = response.headers.get("content-type") ?? ""; + if (!type.includes("application/json")) { + // Usually the SPA fallback returning index.html for a URL the + // API doesn't serve. Parsing it would throw something useless. + throw new ApiError("Server did not return JSON.", { + status: response.status, + }); + } + + const body = await response.json(); + + if (!response.ok) { + throw new ApiError(body.error ?? "Request failed.", { + status: response.status, + fields: body.fields, + }); + } + + return body; +} + +/* ── Reads ───────────────────────────────────────────────────── + get("/events") → cached for 60s + get("/events", { ttl: 0 }) → always fresh + get("/events", { fallback }) → fallback on any failure + ───────────────────────────────────────────────────────────── */ + +export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) { + const hit = cache.get(path); + + if (hit && Date.now() - hit.at < ttl) return hit.promise; + + const promise = request(path).catch((err) => { + cache.delete(path); // a failure shouldn't be cached + if (fallback !== undefined) { + console.warn(`api: ${path} failed, using fallback`, err); + return fallback; + } + throw err; + }); + + cache.set(path, { at: Date.now(), promise }); + return promise; +} + +export function invalidate(path) { + if (path) cache.delete(path); + else cache.clear(); +} + +/* ── Writes ──────────────────────────────────────────────────── */ + +export function post(path, data) { + return request(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); +} diff --git a/src/lib/sections.tsx b/src/lib/sections.tsx new file mode 100644 index 0000000..9d69fdb --- /dev/null +++ b/src/lib/sections.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +/* ═══════════════════════════════════════════════════════════════ + SECTION MANIFEST + + A page is a list of sections, and a section is a view over data + the database already holds, narrowed by a filter. The map of + chapters and the vertical list of regions are the same rows seen + two ways; the three bands of retreats are one view seen three + times with a different filter each. + + So a page declares what it wants and this turns it into what + PageShell takes. Every entry looks like: + + { + id, title, blurb, accent, background, // the heading + Component, // the view + props: { ... }, // the filter + views: { // optional toggle + options: ["carousel", "grid"], + default: "carousel", + Toggle: EventCardsToggle, + }, + } + + Sections fetch their own data. The page never does, which is why + there's no loading or error state here — each view handles its + own, and one section failing doesn't blank the page. + + Every Component receives `accent` so its empty and error notices + match the heading above them, and `view` when the entry declares + a toggle. Both are ignored harmlessly by a section that doesn't + want them. + ═══════════════════════════════════════════════════════════════ */ + +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(() => + Object.fromEntries( + manifest + .filter(entry => entry.views) + .map(entry => [ + entry.id, + entry.views.default ?? entry.views.options?.[0] ?? null, + ]), + ), + ); + + 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]; + const Toggle = spec?.Toggle; + + return { + ...heading, + + actions: Toggle ? ( + setView(entry.id, value)} + accent={entry.accent} + options={spec.options} + /> + ) : undefined, + + content: ( + + ), + }; + }); +} diff --git a/src/lib/useResource.js b/src/lib/useResource.js new file mode 100644 index 0000000..51ff7e7 --- /dev/null +++ b/src/lib/useResource.js @@ -0,0 +1,52 @@ +/* ═══════════════════════════════════════════════════════════════ + useResource + + The read hook every page uses: + + const { data, error, loading } = useResource("/events", { + fallback: { events: EVENTS }, // the old static module + }); + + Deliberately small. If the site ever needs mutation, refetch on + focus, or pagination, that's the point to reach for TanStack + Query rather than growing this file. + + Note the `ignore` flag rather than an AbortController: the + request is shared and cached, so cancelling it would throw away + work another component may still want. We just stop writing + state after unmount. + ═══════════════════════════════════════════════════════════════ */ + +import { useEffect, useState } from "react"; +import { get } from "./api.js"; + +export function useResource(path, { ttl, fallback } = {}) { + // Seed with the fallback so the first paint has content when one + // is available, rather than flashing a spinner and then the same + // data a moment later. + const [state, setState] = useState(() => ({ + data: fallback, + error: null, + loading: true, + })); + + useEffect(() => { + let ignore = false; + + setState((prev) => ({ ...prev, loading: true, error: null })); + + get(path, { ttl, fallback }) + .then((data) => { + if (!ignore) setState({ data, error: null, loading: false }); + }) + .catch((error) => { + if (!ignore) setState((prev) => ({ ...prev, error, loading: false })); + }); + + return () => { + ignore = true; + }; + }, [path, ttl]); // fallback is intentionally not a dependency + + return state; +} diff --git a/src/pages/Chapters.tsx b/src/pages/Chapters.tsx deleted file mode 100644 index 3016e46..0000000 --- a/src/pages/Chapters.tsx +++ /dev/null @@ -1,717 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - GROUPS, - CHAPTERS, - groupOf, - groupSubtext, - DOMESTIC, - INTERNATIONAL, - VIRTUAL, - SPLITS, - STATE_GRID, - MAP_BANDS, - STATE_NAMES, - GROUP_BY_ID, - GROUP_BY_STATE, - groupsForState, - CHAPTER_COUNT_BY_STATE, - chaptersIn, -} from "./chapters.js"; - -/* ═══════════════════════════════════════════════════════════════ - LOCAL CHAPTERS - A tile-grid map on the left, the chapter list on the right. - Selecting a region on either side filters both. - - Why a tile grid rather than a geographic map: every state reads - at the same size (so Rhode Island is as clickable as Texas), it - stays legible on a phone, it needs no map library or GeoJSON, - and a state split between two regions is just a tile painted in - two colors. If you later want true geography, the swap point is - — everything else works off the data. - ═══════════════════════════════════════════════════════════════ */ - -const TILE = 100; -const PAD = 5; -const COLS = 13; -const ROWS = 7; - -// How wide the map + list block runs. The section heading above it -// stays at max-w-6xl, so this deliberately breaks out past it. -const CONTENT_MAX = "88rem"; - -function Tile({ - code, - x, - y, - size, - width = size, - label, - selected, - hovered, - setSelected, - setHovered, - onPick, - fontSize = 34, -}) { - const split = SPLITS[code]; - const [primary, secondary] = groupsForState(code); - if (!primary) return null; - - const count = CHAPTER_COUNT_BY_STATE[code] || 0; - const ids = [primary.id, secondary?.id].filter(Boolean); - const active = ids.includes(selected) || ids.includes(hovered); - const dimmed = selected && !ids.includes(selected); - const clipId = `clip-${code}`; - - // Clicking a split tile cycles primary → secondary → clear, so - // both halves are reachable without a second control. - const cycle = () => { - onPick?.(code); - if (!secondary) return setSelected(selected === primary.id ? null : primary.id); - if (selected === primary.id) return setSelected(secondary.id); - if (selected === secondary.id) return setSelected(null); - setSelected(primary.id); - }; - - const sliverH = split ? size * split.share : 0; - const sliverY = split && split.edge === "top" ? y : y + size - sliverH; - - return ( - setHovered(primary.id)} - onMouseLeave={() => setHovered(null)} - style={{ cursor: "pointer" }} - opacity={dimmed ? 0.25 : 1} - className="transition-opacity duration-200" - > - - {STATE_NAMES[code] || code} —{" "} - {secondary ? `${primary.name} / ${secondary.name}` : primary.name} - {count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""} - - - - - - - - - {split && ( - - )} - - - - - - {label || code} - - - {count > 0 && ( - - )} - - ); -} - -function RegionMap(props) { - const size = TILE - PAD * 2; - // `onPick` rides along in props to each Tile via the spread below. - - return ( - - {/* Wide areas (Canada) */} - {Object.entries(MAP_BANDS).map(([code, band]) => { - const [c1, c2] = band.cols; - return ( - - ); - })} - - {/* States */} - {Object.entries(STATE_GRID).map(([code, [col, row]]) => ( - - ))} - - ); -} - -function LegendButton({ group, selected, setSelected, hovered, setHovered }) { - const on = selected === group.id || hovered === group.id; - return ( - - ); -} - -function Legend(props) { - const { selected, setSelected } = props; - const onMap = g => g.states.length > 0 || g.id === "west-central"; - const us = DOMESTIC.filter(onMap); - const intl = INTERNATIONAL.filter(onMap); - - return ( -
-

- US Regions -

-
- {us.map(g => ( - - ))} -
- -
- -

- International -

-
- {intl.map(g => ( - - ))} - {selected && ( - - )} -
-
- ); -} - -function GroupBlock({ - group, - selected, - setSelected, - setHovered, - indent, - groupRefs, - chapterRefs, -}) { - const chapters = chaptersIn(group.id); - const on = selected === group.id; - - return ( -
groupRefs && (groupRefs.current[group.id] = el)} - className="transition-opacity duration-200" - style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }} - > - - - {groupSubtext(group) && ( -

- {groupSubtext(group)} -

- )} - - {chapters.length === 0 ? ( -

- No chapters yet — interested in starting one? -

- ) : ( -
    - {chapters.map(c => ( -
  • chapterRefs && (chapterRefs.current[c.id] = el)} - className="pl-3" - style={{ borderLeft: `2px solid ${group.color}` }} - > -

    {c.name}

    -

    - {c.city} - {c.meets ? ` · ${c.meets}` : ""} -

    - {(c.link || c.contact) && ( -

    - {c.link && ( - - Details - - )} - {c.contact && ( - - Contact - - )} -

    - )} -
  • - ))} -
- )} -
- ); -} - - -/* ═══════════════════════════════════════════════════════════════ - GRID VIEW - One grid per region. Clicking a card's arrow opens a detail - panel directly above that region's grid, framed in the region - color. Selecting another card swaps the panel's contents. - ═══════════════════════════════════════════════════════════════ */ - -const LOGO_BASE = "/chapter-logos/"; - -/* Logo, or the chapter's initials when there's no file. */ -function ChapterLogo({ chapter, color, size = "h-14 w-14" }) { - const [failed, setFailed] = useState(false); - const initials = chapter.name - .replace(/^NGU\s+/i, "") - .split(/\s+/) - .slice(0, 2) - .map(w => w[0]) - .join("") - .toUpperCase(); - - if (chapter.logo && !failed) { - return ( - {chapter.name} setFailed(true)} - className={`${size} object-contain rounded-xl shrink-0`} - /> - ); - } - - return ( -
- {initials} -
- ); -} - -function ChapterCard({ chapter, color, open, onOpen }) { - return ( -
- - -
-

{chapter.name}

-

{chapter.city}

- {chapter.meets && ( -

{chapter.meets}

- )} -
- - -
- ); -} - -function ChapterDetail({ chapter, group, onClose }) { - const rows = [ - ["Region", group.name], - ["Where", chapter.where], - ["Meets", chapter.meets], - ["Led by", chapter.leads], - ["Since", chapter.started], - ].filter(([, v]) => v); - - return ( -
-
- - -
-

{chapter.name}

-

{chapter.city}

-
- - -
- - {chapter.about && ( -

{chapter.about}

- )} - - {rows.length > 0 && ( -
- {rows.map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
- )} - - {(chapter.link || chapter.contact) && ( -
- {chapter.link && ( - - Visit page - - )} - {chapter.contact && ( - - Get in touch - - )} -
- )} -
- ); -} - -function ChapterGrid({ openId, setOpenId }) { - // Only regions that actually have chapters get a grid. - const populated = GROUPS.map(g => ({ group: g, list: chaptersIn(g.id) })).filter( - ({ list }) => list.length > 0 - ); - const empty = GROUPS.filter(g => chaptersIn(g.id).length === 0); - const openChapter = CHAPTERS.find(c => c.id === openId) || null; - - return ( -
- {populated.map(({ group, list }) => ( -
-
- -

- {group.name} -

- {list.length} -
- {groupSubtext(group) && ( -

{groupSubtext(group)}

- )} - - {/* Detail panel sits above this region's grid, and only - when the open chapter belongs to this region. */} - {openChapter && groupOf(openChapter)?.id === group.id && ( - setOpenId(null)} - /> - )} - -
- {list.map(c => ( - setOpenId(openId === c.id ? null : c.id)} - /> - ))} -
-
- ))} - - {empty.length > 0 && ( -

- No chapters yet in {empty.map(g => g.name).join(", ")} — interested in - starting one? -

- )} -
- ); -} - -/* The control for the section heading's action bar. */ -export function ChaptersViewToggle({ view, setView, accent }) { - const btn = active => ({ - background: active ? accent : "transparent", - color: active ? "#ffffff" : accent, - }); - - return ( -
- {[ - ["map", "Map"], - ["grid", "Grid"], - ].map(([id, label]) => ( - - ))} -
- ); -} - -export default function LocalChapters({ view = "map" }) { - 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(null); - const groupRefs = useRef({}); - const chapterRefs = useRef({}); - - const scrollListTo = el => { - const box = listRef.current; - if (!box || !el) return; - // Only when the list is its own scroll area (lg and up). Below - // that it's stacked under the map and scrolling it would fight - // the page. - if (box.scrollHeight <= box.clientHeight) return; - box.scrollTo({ top: el.offsetTop - 8, behavior: "smooth" }); - }; - - // Hovering or selecting a region brings that block into view. - useEffect(() => { - const id = hovered || selected; - if (id) scrollListTo(groupRefs.current[id]); - }, [hovered, selected]); - - // Clicking a state jumps to its first chapter when it has one, - // otherwise to the region it belongs to. - const pickState = code => { - const chapter = CHAPTERS.find(c => c.state === code); - if (chapter && chapterRefs.current[chapter.id]) { - return scrollListTo(chapterRefs.current[chapter.id]); - } - const group = GROUP_BY_STATE[code]; - if (group) scrollListTo(groupRefs.current[group.id]); - }; - - const shared = { selected, setSelected, hovered, setHovered }; - const listProps = { ...shared, groupRefs, chapterRefs }; - - if (view === "grid") { - return ; - } - - return ( - /* Wider than the section heading above it — the map needs the - room. CONTENT_MAX is the knob; drop it toward 72rem to pull - the whole block back in line with the heading. */ -
-
- {/* Map */} -
- - -

- A filled tile means a chapter meets there; a two-tone tile is a - state shared by two regions. Select a region to filter the list. -

-
- - {/* List — h-0 + min-h-full makes this column take its height - from the map column rather than the other way round, so a - long list scrolls instead of stretching the section. */} -
-

US Regions

- {DOMESTIC.map(g => ( - - ))} - -

- International -

- {INTERNATIONAL.map(g => ( - - ))} - -
- {VIRTUAL.map(g => ( - - ))} -
-
-
-
- ); -} - diff --git a/src/pages/Community.tsx b/src/pages/Community.tsx index 65133e1..3a7dc6f 100644 --- a/src/pages/Community.tsx +++ b/src/pages/Community.tsx @@ -1,63 +1,69 @@ -import { useState } from "react"; -import PageShell from "../components/PageShell.jsx"; -import LocalChapters, { ChaptersViewToggle } from "./Chapters.jsx"; +import PageShell from "../components/PageShell.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"; -/* Section ids match the nav hashes: #local, #virtual, #partners. - `content` is whatever you want inside — cards, a list, plain - copy. The empty divs below are placeholders to build into. */ -const LOCAL_ACCENT = "#138ba0"; +/* ═══════════════════════════════════════════════════════════════ + COMMUNITY PAGE + + Two views of the organizations table. The map is filtered to + chapters; the vertical list is filtered to regions. Same rows, + same endpoint, different shape on the page. + ═══════════════════════════════════════════════════════════════ */ + +const SECTIONS = [ + { + id: "chapters", + title: "Local Chapters", + blurb: + "Young adult groups meeting in person and online across the movement.", + accent: "#138ba0", + background: "#eef9fb", + Component: OrgListMap, + views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle }, + }, + { + id: "regions", + title: "Unity Regions", + blurb: + "The regional organizations that support chapters and host their own gatherings.", + accent: "#4a6b72", + background: "#ffffff", + Component: OrgListVertical, + props: { + kind: "region", + pageLabel: "Region page", + groupBy: org => org.details?.scope, + groups: [ + { key: "domestic", title: "US Unity Regions" }, + { key: "international", title: "International Unity Regions" }, + ], + }, + }, + { + id: "partners", + title: "Partner Organizations", + blurb: + "Organizations we collaborate with across the Unity movement and beyond.", + accent: "#7a5ea8", + background: "#eef9fb", + Component: OrgListCards, + props: { + kind: "partner", + pageLabel: "Partner page", + empty: "· Partner organizations coming soon ·", + }, + }, +]; export default function CommunityPage() { - // Map or grid for the chapters section. Lives here so the toggle in - // the heading's action bar and the content below it stay in sync. - const [chaptersView, setChaptersView] = useState("map"); - - const sections = [ - { - id: "local", - title: "Local Chapters", - blurb: "Groups meeting in person around the country. Find one near you.", - accent: LOCAL_ACCENT, - background: "#eef9fb", - actions: ( - - ), - content: , - }, - { - id: "virtual", - title: "Virtual Community", - blurb: "Connect from anywhere, no chapter nearby required.", - accent: "#aac992", - background: "#ffffff", - content: ( -
- {/* Virtual community content */} -
- ), - }, - { - id: "partners", - title: "Partner Organizations", - blurb: "Organizations we collaborate with across the Unity movement.", - accent: "#7a5ea8", - background: "#eef9fb", - content: ( -
- {/* Partner organizations content */} -
- ), - }, - ]; + const sections = useSectionManifest(SECTIONS); return ( ); diff --git a/src/pages/Feedback.tsx b/src/pages/Feedback.tsx index 8d23da8..c6caf8c 100644 --- a/src/pages/Feedback.tsx +++ b/src/pages/Feedback.tsx @@ -1,302 +1,39 @@ -import { useState } from "react"; -import PageShell from "../components/PageShell.jsx"; +import PageShell from "../components/PageShell"; +import FeedbackForm from "./sections/FeedbackForm"; /* ═══════════════════════════════════════════════════════════════ Feedback page. Section ids match the nav hashes. Right now there's one: - #website. Add more entries to SECTIONS (bottom of file) as the - page grows — program feedback, retreat surveys, etc. + #website. Add more entries to SECTIONS as the page grows — + program feedback, retreat surveys, etc. - Nothing is persisted. handleSubmit just flips to the thank-you - panel; the POST goes where the comment marks it. + The form itself lives in sections/FeedbackForm, including its + own type list, location picker, and the POST to /api/feedback. + This file only decides where it sits on the page. ═══════════════════════════════════════════════════════════════ */ const ACCENT = "#138ba0"; -const MUTED = "#4a6b72"; -const MAX_CHARS = 1500; - -// Selectable feedback types. Add or reword freely — the grid reflows. -const FEEDBACK_TYPES = [ - { - id: "broken", - label: "Something's broken", - hint: "A link, image, or button that doesn't work", - }, - { - id: "confusing", - label: "Hard to use", - hint: "Something you couldn't find or follow", - }, - { - id: "outdated", - label: "Wrong or missing info", - hint: "Old dates, typos, an event that isn't listed", - }, - { - id: "request", - label: "Feature request", - hint: "Something you'd like the site to do", - }, - { - id: "praise", - label: "Kind words", - hint: "Tell us what's working well", - }, - { - id: "other", - label: "Something else", - hint: "Anything that doesn't fit the boxes above", - }, -]; - -/* ── Shared field chrome ─────────────────────────────────────── */ - -const fieldClass = - "w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " + - "placeholder:text-[#4a6b72]/50 outline-none transition-colors " + - "focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"; - -function OptionalTag() { - return ( - - Optional - - ); -} - -/* ── Type picker ─────────────────────────────────────────────── */ - -function TypePicker({ value, onChange }) { - return ( -
- - What kind of feedback is this? - -

- Pick the closest fit. It helps us route it to the right person. -

- -
- {FEEDBACK_TYPES.map((type) => { - const selected = value === type.id; - return ( - - ); - })} -
-
- ); -} - -/* ── The form ────────────────────────────────────────────────── */ - -function WebsiteFeedbackForm() { - const [type, setType] = useState(null); - const [message, setMessage] = useState(""); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [sent, setSent] = useState(false); - - const ready = Boolean(type) && message.trim().length > 0; - - function handleSubmit(event) { - event.preventDefault(); - if (!ready) return; - // TODO: POST { type, message, name, email } somewhere. - setSent(true); - } - - function reset() { - setType(null); - setMessage(""); - setName(""); - setEmail(""); - setSent(false); - } - - if (sent) { - return ( -
-

- Thanks — we've got it -

-

- {email - ? `We'll follow up at ${email} if we have questions.` - : "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."} -

- -
- ); - } - - return ( -
- - - {/* Message */} -
- -

- The page you were on and what you expected to happen are the two most - useful things you can give us. -

-