v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
ff5ea50e7a
37 changed files with 6414 additions and 1988 deletions
18
server/package.json
Normal file
18
server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
120
server/src/db.js
Normal file
120
server/src/db.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
84
server/src/index.js
Normal file
84
server/src/index.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
15
server/src/migrate-cli.js
Normal file
15
server/src/migrate-cli.js
Normal file
|
|
@ -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}`);
|
||||
16
server/src/migrations/001_init.sql
Normal file
16
server/src/migrations/001_init.sql
Normal file
|
|
@ -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'));
|
||||
700
server/src/migrations/002_schema.sql
Normal file
700
server/src/migrations/002_schema.sql
Normal file
|
|
@ -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;
|
||||
49
server/src/rateLimit.js
Normal file
49
server/src/rateLimit.js
Normal file
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
397
server/src/routes/content.js
Normal file
397
server/src/routes/content.js
Normal file
|
|
@ -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;
|
||||
130
server/src/routes/feedback.js
Normal file
130
server/src/routes/feedback.js
Normal file
|
|
@ -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;
|
||||
398
server/src/seed.js
Normal file
398
server/src/seed.js
Normal file
|
|
@ -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("");
|
||||
147
server/src/shape.js
Normal file
147
server/src/shape.js
Normal file
|
|
@ -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 };
|
||||
30
src/components/ArrowLink.tsx
Normal file
30
src/components/ArrowLink.tsx
Normal file
|
|
@ -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 (
|
||||
<Link
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`${size} shrink-0 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
365
src/components/PeopleTiles.css
Normal file
365
src/components/PeopleTiles.css
Normal file
|
|
@ -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: <PeopleTiles style={{ "--pl-base-w": "150px" }} /> */
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
375
src/components/PeopleTiles.tsx
Normal file
375
src/components/PeopleTiles.tsx
Normal file
|
|
@ -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:
|
||||
* <PeopleTiles size="md" groups={staffGroups} />
|
||||
* <PeopleTiles size="sm" people={volunteers} />
|
||||
*
|
||||
* 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 ? <p className="pl__empty">{emptyMessage}</p> : 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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`pl ${className}`.trim()}
|
||||
data-size={resolvedSize}
|
||||
data-overflow={overflow}
|
||||
data-align={align}
|
||||
data-tilt={tilt ? "on" : "off"}
|
||||
style={{
|
||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||
...(accent ? { "--pl-accent": accent } : null),
|
||||
...style,
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
<div className="pl__groups">
|
||||
{resolvedGroups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
className="pl__group"
|
||||
style={group.accent ? { "--pl-accent": group.accent } : undefined}
|
||||
aria-label={group.label || undefined}
|
||||
>
|
||||
{(group.label || group.note) && (
|
||||
<header className="pl__group-head">
|
||||
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
|
||||
{group.note && <p className="pl__group-note">{group.note}</p>}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<ul className="pl__row">
|
||||
{group.people.map((person, index) => {
|
||||
const key = keyFor(group, person, index);
|
||||
const expandable = features.bio && hasBio(person);
|
||||
const isOpen = expandable && openKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="pl__item"
|
||||
style={person.accent ? { "--pl-accent": person.accent } : undefined}
|
||||
>
|
||||
<Tile
|
||||
person={person}
|
||||
showTitle={features.title}
|
||||
expandable={expandable}
|
||||
isOpen={isOpen}
|
||||
panelId={`${baseId}-bio`}
|
||||
onToggle={() => toggle(group, person, index)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<BioPanel
|
||||
id={`${baseId}-bio`}
|
||||
person={open.person}
|
||||
group={open.group}
|
||||
onClose={() => {
|
||||
setOpenKey(null);
|
||||
if (onExpand) onExpand(null, null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="pl__frame">
|
||||
<span className="pl__photo">
|
||||
<Photo src={person.photo} name={person.name} />
|
||||
</span>
|
||||
<span className="pl__caption">
|
||||
<span className="pl__name">{person.name}</span>
|
||||
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
|
||||
</span>
|
||||
{expandable && (
|
||||
<span className="pl__badge" aria-hidden="true">
|
||||
<Chevron />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!expandable) {
|
||||
return <div className="pl__tile">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pl__tile pl__tile--button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{content}
|
||||
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ src, name }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<span className="pl__initials" aria-hidden="true">
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="pl__img"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => 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 (
|
||||
<div
|
||||
id={id}
|
||||
className="pl__bio"
|
||||
role="region"
|
||||
aria-label={`About ${person.name}`}
|
||||
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
|
||||
>
|
||||
<div className="pl__bio-head">
|
||||
<div>
|
||||
<p className="pl__bio-name">{person.name}</p>
|
||||
{person.title && <p className="pl__bio-title">{person.title}</p>}
|
||||
</div>
|
||||
<button type="button" className="pl__close" onClick={onClose}>
|
||||
<span className="pl__sr">Close bio</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(person.pronouns || age != null || org) && (
|
||||
<dl className="pl__facts">
|
||||
{person.pronouns && (
|
||||
<div className="pl__fact">
|
||||
<dt>Pronouns</dt>
|
||||
<dd>{person.pronouns}</dd>
|
||||
</div>
|
||||
)}
|
||||
{age != null && (
|
||||
<div className="pl__fact">
|
||||
<dt>Age</dt>
|
||||
<dd>{age}</dd>
|
||||
</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="pl__fact">
|
||||
<dt>Home organization</dt>
|
||||
<dd>
|
||||
{org.href ? (
|
||||
<a href={org.href} target="_blank" rel="noreferrer">
|
||||
{org.name}
|
||||
</a>
|
||||
) : (
|
||||
org.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
||||
<p key={index} className="pl__bio-text">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
|
||||
<path
|
||||
d="M4 6.5 8 10.5 12 6.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
146
src/data/chapters.js
Normal file
146
src/data/chapters.js
Normal file
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
54
src/data/eventData.js
Normal file
54
src/data/eventData.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
174
src/data/mapGrid.js
Normal file
174
src/data/mapGrid.js
Normal file
|
|
@ -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(", ");
|
||||
}
|
||||
90
src/data/organizations.js
Normal file
90
src/data/organizations.js
Normal file
|
|
@ -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("");
|
||||
}
|
||||
140
src/index.css
140
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/lib/api.js
Normal file
102
src/lib/api.js
Normal file
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
78
src/lib/sections.tsx
Normal file
78
src/lib/sections.tsx
Normal file
|
|
@ -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 ? (
|
||||
<Toggle
|
||||
view={view}
|
||||
setView={value => setView(entry.id, value)}
|
||||
accent={entry.accent}
|
||||
options={spec.options}
|
||||
/>
|
||||
) : undefined,
|
||||
|
||||
content: (
|
||||
<Component
|
||||
{...(props ?? {})}
|
||||
accent={entry.accent}
|
||||
{...(spec ? { view } : {})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
52
src/lib/useResource.js
Normal file
52
src/lib/useResource.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
<RegionMap> — 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 (
|
||||
<g
|
||||
onClick={cycle}
|
||||
onMouseEnter={() => setHovered(primary.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
<title>
|
||||
{STATE_NAMES[code] || code} —{" "}
|
||||
{secondary ? `${primary.name} / ${secondary.name}` : primary.name}
|
||||
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
|
||||
</title>
|
||||
|
||||
<clipPath id={clipId}>
|
||||
<rect x={x} y={y} width={width} height={size} rx={14} />
|
||||
</clipPath>
|
||||
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
fill={primary.color}
|
||||
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
{split && (
|
||||
<rect
|
||||
x={x}
|
||||
y={sliverY}
|
||||
width={width}
|
||||
height={sliverH}
|
||||
fill={secondary.color}
|
||||
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + size / 2 + 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
</text>
|
||||
|
||||
{count > 0 && (
|
||||
<circle
|
||||
cx={x + width - 14}
|
||||
cy={y + 14}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.9}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionMap(props) {
|
||||
const size = TILE - PAD * 2;
|
||||
// `onPick` rides along in props to each Tile via the spread below.
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
|
||||
className="w-full h-auto"
|
||||
role="group"
|
||||
aria-label="Chapters by region"
|
||||
>
|
||||
{/* Wide areas (Canada) */}
|
||||
{Object.entries(MAP_BANDS).map(([code, band]) => {
|
||||
const [c1, c2] = band.cols;
|
||||
return (
|
||||
<Tile
|
||||
key={code}
|
||||
code={code}
|
||||
label={band.label}
|
||||
x={(c1 - 1) * TILE + PAD}
|
||||
y={(band.row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
width={(c2 - c1 + 1) * TILE - PAD * 2}
|
||||
fontSize={38}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* States */}
|
||||
{Object.entries(STATE_GRID).map(([code, [col, row]]) => (
|
||||
<Tile
|
||||
key={code}
|
||||
code={code}
|
||||
x={(col - 1) * TILE + PAD}
|
||||
y={(row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendButton({ group, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === group.id || hovered === group.id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === group.id ? null : group.id)}
|
||||
onMouseEnter={() => setHovered(group.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(group.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-pressed={selected === group.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${group.color}`,
|
||||
background: on ? group.color : "transparent",
|
||||
color: on ? "#ffffff" : group.color,
|
||||
opacity: selected && selected !== group.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : group.color }}
|
||||
/>
|
||||
{group.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
|
||||
US Regions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{us.map(g => (
|
||||
<LegendButton key={g.id} group={g} {...props} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px my-4 bg-[#cfe3e7]" />
|
||||
|
||||
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
|
||||
International
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{intl.map(g => (
|
||||
<LegendButton key={g.id} group={g} {...props} />
|
||||
))}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="py-1.5 px-3 rounded-lg text-sm font-700 text-[#4a6b72] underline"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupBlock({
|
||||
group,
|
||||
selected,
|
||||
setSelected,
|
||||
setHovered,
|
||||
indent,
|
||||
groupRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
const chapters = chaptersIn(group.id);
|
||||
const on = selected === group.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => groupRefs && (groupRefs.current[group.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(on ? null : group.id)}
|
||||
onMouseEnter={() => setHovered(group.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full flex items-baseline gap-2 text-left py-2"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: group.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
{group.name}
|
||||
</h4>
|
||||
<span className="text-sm text-[#7a9299] ml-auto">
|
||||
{chapters.length || "—"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{groupSubtext(group) && (
|
||||
<p className="text-sm text-[#7a9299] mb-2 ml-5 leading-snug">
|
||||
{groupSubtext(group)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm text-[#7a9299] ml-5 mb-4">
|
||||
No chapters yet — interested in starting one?
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3"
|
||||
style={{ borderLeft: `2px solid ${group.color}` }}
|
||||
>
|
||||
<p className="font-700">{c.name}</p>
|
||||
<p className="text-sm text-[#4a6b72]">
|
||||
{c.city}
|
||||
{c.meets ? ` · ${c.meets}` : ""}
|
||||
</p>
|
||||
{(c.link || c.contact) && (
|
||||
<p className="text-sm mt-1 flex gap-4">
|
||||
{c.link && (
|
||||
<a
|
||||
href={c.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
{c.contact && (
|
||||
<a
|
||||
href={`mailto:${c.contact}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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 (
|
||||
<img
|
||||
src={`${LOGO_BASE}${chapter.logo}`}
|
||||
alt={chapter.name}
|
||||
onError={() => setFailed(true)}
|
||||
className={`${size} object-contain rounded-xl shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-label={chapter.name}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: open ? `${color}14` : "transparent",
|
||||
}}
|
||||
>
|
||||
<ChapterLogo chapter={chapter} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700 leading-tight">{chapter.name}</p>
|
||||
<p className="text-sm text-[#4a6b72]">{chapter.city}</p>
|
||||
{chapter.meets && (
|
||||
<p className="text-sm text-[#7a9299]">{chapter.meets}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
|
||||
className="shrink-0 h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${group.color}`, background: `${group.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<ChapterLogo chapter={chapter} color={group.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
<p className="text-[#4a6b72]">{chapter.city}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chapter.about && (
|
||||
<p className="mt-4 leading-relaxed text-[#2c4a50]">{chapter.about}</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2">
|
||||
<dt className="text-[#7a9299] shrink-0">{label}</dt>
|
||||
<dd className="font-700 text-[#2c4a50]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{(chapter.link || chapter.contact) && (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{chapter.link && (
|
||||
<a
|
||||
href={chapter.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
Visit page
|
||||
</a>
|
||||
)}
|
||||
{chapter.contact && (
|
||||
<a
|
||||
href={`mailto:${chapter.contact}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${group.color}`, color: group.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ group, list }) => (
|
||||
<section key={group.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: group.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: group.color }}>
|
||||
{group.name}
|
||||
</h3>
|
||||
<span className="text-sm text-[#7a9299]">{list.length}</span>
|
||||
</div>
|
||||
{groupSubtext(group) && (
|
||||
<p className="text-sm text-[#7a9299] mb-5 ml-6">{groupSubtext(group)}</p>
|
||||
)}
|
||||
|
||||
{/* Detail panel sits above this region's grid, and only
|
||||
when the open chapter belongs to this region. */}
|
||||
{openChapter && groupOf(openChapter)?.id === group.id && (
|
||||
<ChapterDetail
|
||||
chapter={openChapter}
|
||||
group={group}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-4 items-start"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
|
||||
}}
|
||||
>
|
||||
{list.map(c => (
|
||||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={group.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{empty.length > 0 && (
|
||||
<p className="text-sm text-[#7a9299]">
|
||||
No chapters yet in {empty.map(g => g.name).join(", ")} — interested in
|
||||
starting one?
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<div
|
||||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||||
style={{ border: `1px solid ${accent}` }}
|
||||
role="group"
|
||||
aria-label="Change how chapters are displayed"
|
||||
>
|
||||
{[
|
||||
["map", "Map"],
|
||||
["grid", "Grid"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
aria-pressed={view === id}
|
||||
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <ChapterGrid openId={openId} setOpenId={setOpenId} />;
|
||||
}
|
||||
|
||||
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. */
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<RegionMap {...shared} onPick={pickState} />
|
||||
<Legend {...shared} />
|
||||
<p className="text-sm text-[#7a9299] mt-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<h3 className="text-xl font-800 mb-1 text-[#4a6b72]">US Regions</h3>
|
||||
{DOMESTIC.map(g => (
|
||||
<GroupBlock key={g.id} group={g} indent {...listProps} />
|
||||
))}
|
||||
|
||||
<h3 className="text-xl font-800 mt-6 mb-1 pt-4 border-t border-[#cfe3e7] text-[#4a6b72]">
|
||||
International
|
||||
</h3>
|
||||
{INTERNATIONAL.map(g => (
|
||||
<GroupBlock key={g.id} group={g} indent {...listProps} />
|
||||
))}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-[#cfe3e7]">
|
||||
{VIRTUAL.map(g => (
|
||||
<GroupBlock key={g.id} group={g} {...listProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
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");
|
||||
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 = [
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "local",
|
||||
id: "chapters",
|
||||
title: "Local Chapters",
|
||||
blurb: "Groups meeting in person around the country. Find one near you.",
|
||||
accent: LOCAL_ACCENT,
|
||||
blurb:
|
||||
"Young adult groups meeting in person and online across the movement.",
|
||||
accent: "#138ba0",
|
||||
background: "#eef9fb",
|
||||
actions: (
|
||||
<ChaptersViewToggle
|
||||
view={chaptersView}
|
||||
setView={setChaptersView}
|
||||
accent={LOCAL_ACCENT}
|
||||
/>
|
||||
),
|
||||
content: <LocalChapters view={chaptersView} />,
|
||||
Component: OrgListMap,
|
||||
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
|
||||
},
|
||||
{
|
||||
id: "virtual",
|
||||
title: "Virtual Community",
|
||||
blurb: "Connect from anywhere, no chapter nearby required.",
|
||||
accent: "#aac992",
|
||||
id: "regions",
|
||||
title: "Unity Regions",
|
||||
blurb:
|
||||
"The regional organizations that support chapters and host their own gatherings.",
|
||||
accent: "#4a6b72",
|
||||
background: "#ffffff",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Virtual community content */}
|
||||
</div>
|
||||
),
|
||||
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.",
|
||||
blurb:
|
||||
"Organizations we collaborate with across the Unity movement and beyond.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Partner organizations content */}
|
||||
</div>
|
||||
),
|
||||
Component: OrgListCards,
|
||||
props: {
|
||||
kind: "partner",
|
||||
pageLabel: "Partner page",
|
||||
empty: "· Partner organizations coming soon ·",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function CommunityPage() {
|
||||
const sections = useSectionManifest(SECTIONS);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Community"
|
||||
intro="Ways to connect with Next Generation of Unity, in your area, online, and through the organizations we work alongside."
|
||||
intro="Chapters, regions and the people who make up Next Generation of Unity."
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
Optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
What kind of feedback is this?
|
||||
</legend>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
Pick the closest fit. It helps us route it to the right person.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEEDBACK_TYPES.map((type) => {
|
||||
const selected = value === type.id;
|
||||
return (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feedbackType"
|
||||
value={type.id}
|
||||
checked={selected}
|
||||
onChange={() => onChange(type.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#eef9fb]"
|
||||
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-[#26454c]">
|
||||
{type.label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
|
||||
: "border-[#4a6b72]/30")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{type.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
|
||||
Thanks — we've got it
|
||||
</h3>
|
||||
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
|
||||
{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."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<TypePicker value={type} onChange={setType} />
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-10">
|
||||
<label
|
||||
htmlFor="feedback-message"
|
||||
className="text-base font-semibold text-[#26454c]"
|
||||
>
|
||||
Tell us more
|
||||
</label>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
The page you were on and what you expected to happen are the two most
|
||||
useful things you can give us.
|
||||
</p>
|
||||
<textarea
|
||||
id="feedback-message"
|
||||
rows={7}
|
||||
maxLength={MAX_CHARS}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I was looking for the summer retreat dates on the Events page and…"
|
||||
className={`mt-4 resize-y ${fieldClass}`}
|
||||
/>
|
||||
<div className="mt-2 text-right text-xs tabular-nums" style={{ color: MUTED }}>
|
||||
{message.length} / {MAX_CHARS}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional contact details */}
|
||||
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">
|
||||
Your details
|
||||
</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave these blank and your feedback comes through anonymously. Fill
|
||||
them in only if you'd like a reply — we won't add you to any list.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-name"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready}
|
||||
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
|
||||
>
|
||||
Send feedback
|
||||
</button>
|
||||
{!ready && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
Choose a type and write a note to send.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────── */
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "website",
|
||||
title: "Website Feedback and Requests",
|
||||
title: "Website Feedback",
|
||||
blurb:
|
||||
"Found a broken link, spotted something out of date, or want the site to do something it doesn't? This form goes straight to the people who maintain it.",
|
||||
accent: ACCENT,
|
||||
background: "#eef9fb",
|
||||
content: (
|
||||
// Narrower than the usual max-w-6xl: full-width form fields
|
||||
// are unpleasant to fill in.
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<WebsiteFeedbackForm />
|
||||
<FeedbackForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function FeedbackPage() {
|
||||
export default function Feedback() {
|
||||
return (
|
||||
<PageShell
|
||||
title="Feedback"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,62 @@
|
|||
import PageShell from "../components/PageShell.jsx";
|
||||
import PageShell from "../components/PageShell.tsx";
|
||||
import PeopleTiles from "../components/PeopleTiles.tsx";
|
||||
|
||||
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
|
||||
// the component only cares about the shape, not where it came from.
|
||||
|
||||
export const leadershipGroups = [
|
||||
{
|
||||
id: "director",
|
||||
label: "Retreat Director",
|
||||
accent: "#138ba0",
|
||||
people: [
|
||||
{
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
title: "Retreat Director",
|
||||
photo: "/people/jordan-ellis.jpg",
|
||||
pronouns: "he/him",
|
||||
birthdate: "1994-03-08",
|
||||
org: { name: "Unity of Des Moines", href: "https://example.org" },
|
||||
bio: [
|
||||
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
|
||||
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
label: "Retreat Team",
|
||||
people: [
|
||||
{
|
||||
id: 2,
|
||||
name: "Priya Raman",
|
||||
title: "Events Lead",
|
||||
photo: "/people/priya-raman.jpg",
|
||||
pronouns: "she/her",
|
||||
age: 27,
|
||||
org: "Unity Chicago",
|
||||
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Sam Okafor",
|
||||
title: "Communications",
|
||||
pronouns: "they/them",
|
||||
age: 24,
|
||||
org: "Unity of Milwaukee",
|
||||
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const board = [
|
||||
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
|
||||
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
|
||||
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
|
|
@ -9,7 +67,7 @@ const SECTIONS = [
|
|||
background: "#eef9fb",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Board & staff content */}
|
||||
<PeopleTiles size="sm" people={board} overflow="scroll" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -21,7 +79,7 @@ const SECTIONS = [
|
|||
background: "#ffffff",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Retreat team content */}
|
||||
<PeopleTiles size="lg" groups={leadershipGroups} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,590 +1,61 @@
|
|||
import { useState } from "react";
|
||||
import PageShell from "../components/PageShell.tsx"
|
||||
import eventsData from "../events.js";
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
const LOGOS = Object.fromEntries(
|
||||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
|
||||
// Used when an event doesn't name its own org_logo.
|
||||
const DEFAULT_ORG_LOGO = null;
|
||||
|
||||
/* An <img> that removes itself if the file 404s. */
|
||||
function Logo({ file, alt = "", className }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = logoSrc(file);
|
||||
if (!src || failed) return null;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TEAL = "#138ba0"; // page heading + last-resort card color
|
||||
|
||||
/* ── Grid view ────────────────────────────────────────────────
|
||||
The grid fits as many columns as the screen allows, with no
|
||||
breakpoints: each card is at least CARD_MIN wide, and the
|
||||
columns share whatever space is left over.
|
||||
CARD_MIN narrowest a card may get before dropping a column
|
||||
MAX_COLS ceiling on columns, so cards don't get absurd on
|
||||
very wide monitors. GRID_MAX is derived from it.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD_MIN = "32rem";
|
||||
const MAX_COLS = 4;
|
||||
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
|
||||
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
|
||||
|
||||
/* Collapsed past-events strip: how much shows, and the downward
|
||||
fade applied while it's collapsed. */
|
||||
const PAST_PEEK = "10rem";
|
||||
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
|
||||
|
||||
/* ── Carousel sizing ──────────────────────────────────────────
|
||||
CARD the card itself (your original max-w-3xl)
|
||||
GAP space between cards — raise to push neighbors out
|
||||
FADE_DIST how far past the card's edge neighbors fade to nothing
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD = "min(48rem, 90vw)";
|
||||
const GAP = "5rem";
|
||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
||||
const FADE_DIST = "18rem";
|
||||
|
||||
const EDGE_FADE = `linear-gradient(to right,
|
||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
||||
black calc(50% - ${HALF_SLIDE}),
|
||||
black calc(50% + ${HALF_SLIDE}),
|
||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||
|
||||
/* ── Grid-card layout ─────────────────────────────────────────
|
||||
Container queries, not media queries: each card reacts to its
|
||||
OWN width, which is what we need now that the column count is
|
||||
dynamic and the viewport says nothing about how wide a card is.
|
||||
|
||||
Wide card NGU logo + details on the left, event logo on the
|
||||
right, descriptions full-width underneath.
|
||||
Narrow card logos on their own row, then details, then
|
||||
descriptions — three stacked rows.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD_STYLES = `
|
||||
.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";
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.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; }
|
||||
`;
|
||||
|
||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ig-icon w-6 h-6"
|
||||
style={{ "--ig-fill": `url(#${id})` }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FEDA75" />
|
||||
<stop offset="25%" stopColor="#FA7E1E" />
|
||||
<stop offset="50%" stopColor="#D62976" />
|
||||
<stop offset="75%" stopColor="#962FBF" />
|
||||
<stop offset="100%" stopColor="#4F5BD5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||
</svg>
|
||||
);
|
||||
import PageShell from "../components/PageShell.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CARD — one component, two sizes.
|
||||
compact=false → the full card used in the carousel
|
||||
compact=true → the grid card: details left, text right, and a
|
||||
footer pinned to the bottom so every card in a
|
||||
row is the same height with aligned buttons.
|
||||
RETREATS PAGE
|
||||
|
||||
Three bands, all the same view of the same table with a
|
||||
different filter. The page declares them and does nothing else —
|
||||
each section fetches its own slice.
|
||||
|
||||
To reorder the page, move a line. To add a different kind of
|
||||
section, add an entry with another Component.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
defaultColor = TEAL,
|
||||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||
const eventLogo = ev.image;
|
||||
const igHandle = ev.instagram || null;
|
||||
const igUrl = igHandle
|
||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: null;
|
||||
|
||||
const descriptions = (
|
||||
<>
|
||||
{ev.desc_a && (
|
||||
<p className={`mb-2 leading-relaxed ${compact ? "text-base" : ""}`}>
|
||||
{ev.desc_a}
|
||||
</p>
|
||||
)}
|
||||
{ev.desc_b && (
|
||||
<p className={`leading-relaxed ${compact ? "text-base" : ""}`}>
|
||||
{ev.desc_b}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
|
||||
compact ? "ev-card" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: ev.gradient || undefined,
|
||||
filter: past ? "saturate(0.75)" : "none",
|
||||
pointerEvents: interactive ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
|
||||
{compact ? (
|
||||
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
|
||||
<div className="ev-grid mb-2">
|
||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||
<div className="ev-info">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date && <p className="text-xl">{ev.date}</p>}
|
||||
{ev.location && <p className="text-xl">{ev.location}</p>}
|
||||
</div>
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="ev-image w-full h-auto object-contain"
|
||||
/>
|
||||
<div className="ev-desc">{descriptions}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── FULL: details left, large event logo right ── */
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date && <p className="text-2xl">{ev.date}</p>}
|
||||
{ev.location && <p className="text-2xl">{ev.location}</p>}
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-center justify-end">
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="w-full h-auto max-h-72 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{descriptions}
|
||||
</>
|
||||
)}
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "national",
|
||||
title: "National Retreats",
|
||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
||||
accent: "#138ba0",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "national" },
|
||||
views: { ...CARD_VIEWS, default: "carousel" },
|
||||
},
|
||||
{
|
||||
id: "regional",
|
||||
title: "Regional Retreats",
|
||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
||||
accent: "#aac992",
|
||||
background: "#ffffff",
|
||||
Component: EventListCards,
|
||||
props: { section: "regional" },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
{
|
||||
id: "partner",
|
||||
title: "Partner Events",
|
||||
blurb: "Events hosted by organizations we collaborate with.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "partner" },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
];
|
||||
|
||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{ev.links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{ev.links.map(item => (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.link}
|
||||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${color}` }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className={`mt-auto text-center font-600 ${compact ? "pt-8" : "pt-8"}`}
|
||||
style={{ color: accent }}
|
||||
>
|
||||
This event has concluded — thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
{igHandle && (
|
||||
<a
|
||||
href={igUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
<InstagramIcon id={`ig-${ev.id}`} />
|
||||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
VIEW TOGGLE — carousel / grid segmented control
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
function ViewToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||||
style={{ border: `1px solid ${accent}` }}
|
||||
role="group"
|
||||
aria-label="Change how events are displayed"
|
||||
>
|
||||
<button
|
||||
onClick={() => setView("carousel")}
|
||||
aria-pressed={view === "carousel"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "carousel")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="7" y="5" width="10" height="14" rx="2" />
|
||||
<path d="M3.5 8v8M20.5 8v8" />
|
||||
</svg>
|
||||
Carousel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
aria-pressed={view === "grid"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "grid")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
Grid
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION — heading + toggle, then carousel or grid
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function CardSection({ section, view }) {
|
||||
|
||||
const {
|
||||
events,
|
||||
accent,
|
||||
defaultColor,
|
||||
} = section;
|
||||
|
||||
const startIndex = (() => {
|
||||
const i = events.findIndex(e => e.status === "upcoming");
|
||||
return i === -1 ? Math.max(0, events.length - 1) : i;
|
||||
})();
|
||||
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
|
||||
// Grid view splits the list; the carousel still shows everything.
|
||||
const upcoming = events.filter(e => e.status !== "past");
|
||||
const pastEvents = events.filter(e => e.status === "past");
|
||||
|
||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||
|
||||
// Arrows and dots follow the section accent, not the active card.
|
||||
const arrowStyle = enabled => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: enabled ? accent : "#b8c6c9",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{events.length === 0 ? (
|
||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||
· Events coming soon, stay connected for announcements ·
|
||||
</p>
|
||||
) : view === "grid" ? (
|
||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||
{upcoming.length > 0 && (
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
{upcoming.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={defaultColor}
|
||||
accent={accent}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pastEvents.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3
|
||||
className="text-2xl font-800 mb-6"
|
||||
style={{ color: accent, opacity: 0.85 }}
|
||||
>
|
||||
Past Events
|
||||
</h3>
|
||||
|
||||
{/* Collapsed: clipped to PAST_PEEK and faded out at the
|
||||
bottom. Expanded: full height, no mask. */}
|
||||
<div
|
||||
className="relative transition-all duration-500 ease-out"
|
||||
style={{
|
||||
maxHeight: showPast ? "none" : PAST_PEEK,
|
||||
overflow: showPast ? "visible" : "hidden",
|
||||
maskImage: showPast ? "none" : PAST_FADE,
|
||||
WebkitMaskImage: showPast ? "none" : PAST_FADE,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
aria-hidden={!showPast}
|
||||
>
|
||||
{pastEvents.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={defaultColor}
|
||||
accent={accent}
|
||||
compact
|
||||
interactive={showPast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
onClick={() => setShowPast(v => !v)}
|
||||
aria-expanded={showPast}
|
||||
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
{showPast
|
||||
? "Hide past events"
|
||||
: `See past events (${pastEvents.length})`}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 transition-transform duration-300"
|
||||
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── CAROUSEL VIEW ── */
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
||||
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
|
||||
>
|
||||
{events.map((ev, i) => {
|
||||
const active = i === index;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: SLIDE,
|
||||
padding: `0 calc(${GAP} / 2)`,
|
||||
cursor: active ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => !active && setIndex(i)}
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<Card
|
||||
ev={ev}
|
||||
defaultColor={defaultColor}
|
||||
accent={accent}
|
||||
interactive={active}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous event"
|
||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index > 0)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={index === events.length - 1}
|
||||
aria-label="Next event"
|
||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index < events.length - 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{events.map((e, i) => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Go to ${e.title}`}
|
||||
className="h-2.5 rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: i === index ? "1.5rem" : "0.625rem",
|
||||
background: i === index ? accent : "#b8c6c9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RETREATS PAGE — hands the card sections to the shared PageShell
|
||||
so this page matches Community, Leadership and Resources.
|
||||
|
||||
View state lives here, one entry per section, so the toggle in a
|
||||
section's action bar and the cards below it stay in sync.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export default function RetreatsPage() {
|
||||
const [views, setViews] = useState(() =>
|
||||
Object.fromEntries(
|
||||
eventsData.sections.map(s => [s.id, s.defaultView || "carousel"])
|
||||
)
|
||||
);
|
||||
|
||||
const setView = (id, value) =>
|
||||
setViews(prev => ({ ...prev, [id]: value }));
|
||||
|
||||
const shellSections = eventsData.sections.map(section => {
|
||||
const view = views[section.id];
|
||||
return {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
blurb: section.blurb,
|
||||
accent: section.accent,
|
||||
background: section.background,
|
||||
actions: section.events.length > 0 && (
|
||||
<ViewToggle
|
||||
view={view}
|
||||
setView={value => setView(section.id, value)}
|
||||
accent={section.accent}
|
||||
/>
|
||||
),
|
||||
content: <CardSection section={section} view={view} />,
|
||||
};
|
||||
});
|
||||
const sections = useSectionManifest(SECTIONS);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{CARD_STYLES}</style>
|
||||
<PageShell
|
||||
title="Retreats"
|
||||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
||||
sections={shellSections}
|
||||
sections={sections}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
LOCAL CHAPTER DATA
|
||||
|
||||
GROUPS every group that can appear in the list: the 7
|
||||
domestic regions, the international ones, virtual
|
||||
STATE_GRID where each state sits on the tile map
|
||||
SPLITS states shared by two regions (see below)
|
||||
CHAPTERS the chapters themselves
|
||||
|
||||
To add a chapter: add an object to CHAPTERS. Its group is looked
|
||||
up from its state, so the map and the list can't drift apart —
|
||||
except in a split state, where the chapter names its region.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Groups ───────────────────────────────────────────────────
|
||||
scope "domestic" → its own block in the list
|
||||
"international" → nested under the International heading
|
||||
"virtual" → region-agnostic, listed last
|
||||
states which tiles it owns on the map ([] = not on the map)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const GROUPS = [
|
||||
{
|
||||
id: "eastern",
|
||||
name: "Eastern",
|
||||
color: "#7a5ea8",
|
||||
scope: "domestic",
|
||||
states: [
|
||||
"WV", "VA", "MD", "DC", "NY", "PA", "ME",
|
||||
"NJ", "DE", "CT", "VT", "MA", "NH", "RI",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "great-lakes",
|
||||
name: "Great Lakes",
|
||||
color: "#4a6fa5",
|
||||
scope: "domestic",
|
||||
states: ["MN", "IA", "WI", "IL", "IN", "MI", "OH", "KY"],
|
||||
},
|
||||
{
|
||||
id: "northwest",
|
||||
name: "Northwest",
|
||||
color: "#138ba0",
|
||||
scope: "domestic",
|
||||
states: ["AK", "WA", "OR", "ID", "MT"],
|
||||
},
|
||||
{
|
||||
id: "south-central",
|
||||
name: "South Central",
|
||||
color: "#d4703f",
|
||||
scope: "domestic",
|
||||
states: [
|
||||
"ND", "SD", "WY", "NE", "CO", "NM",
|
||||
"TX", "OK", "KS", "MO", "AR", "LA",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "southeast",
|
||||
name: "Southeast",
|
||||
color: "#c25e8b",
|
||||
scope: "domestic",
|
||||
states: ["TN", "MS", "AL", "GA", "NC", "SC", "FL"],
|
||||
},
|
||||
{
|
||||
id: "southwest",
|
||||
name: "Southwest",
|
||||
color: "#e0a32e",
|
||||
scope: "domestic",
|
||||
states: ["AZ"],
|
||||
},
|
||||
{
|
||||
id: "west-central",
|
||||
name: "West Central",
|
||||
color: "#7a9e5a",
|
||||
scope: "domestic",
|
||||
states: [],
|
||||
},
|
||||
|
||||
|
||||
/* ── International ── */
|
||||
{
|
||||
id: "africa",
|
||||
name: "Africa",
|
||||
color: "#c98a3c",
|
||||
scope: "international",
|
||||
states: [],
|
||||
note: "Not yet shown on the map.",
|
||||
},
|
||||
{
|
||||
id: "canada",
|
||||
name: "Canada",
|
||||
color: "#a8577a",
|
||||
scope: "international",
|
||||
states: ["CANADA"], // a map area, not a US state
|
||||
},
|
||||
{
|
||||
id: "pacific-rim",
|
||||
name: "Pacific Rim",
|
||||
color: "#2f9e8f",
|
||||
scope: "international",
|
||||
states: ["HI"],
|
||||
note: "Hawai'i and the wider Pacific.",
|
||||
},
|
||||
{
|
||||
id: "uk",
|
||||
name: "United Kingdom",
|
||||
color: "#6b7fb5",
|
||||
scope: "international",
|
||||
states: [],
|
||||
note: "Not yet shown on the map.",
|
||||
},
|
||||
|
||||
/* ── Region-agnostic ── */
|
||||
{
|
||||
id: "virtual",
|
||||
name: "Virtual",
|
||||
color: "#8aa1a6",
|
||||
scope: "virtual",
|
||||
states: [],
|
||||
note: "Join from anywhere.",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Split states ─────────────────────────────────────────────
|
||||
Some states belong to two regions. The tile is drawn in the
|
||||
primary region's color, then `share` of it — measured from
|
||||
`edge` — is painted in the secondary region's color.
|
||||
|
||||
primary which group owns the rest of the state
|
||||
secondary which group owns the sliver
|
||||
edge "top" | "bottom" — where the sliver sits
|
||||
share 0-1, how much of the tile the sliver takes
|
||||
primaryNote how that share reads in the region subtext
|
||||
secondaryNote "
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const SPLITS = {
|
||||
CA: {
|
||||
primary: "southwest", secondary: "west-central",
|
||||
edge: "top", share: 0.5,
|
||||
primaryNote: "south", secondaryNote: "north",
|
||||
},
|
||||
NV: {
|
||||
primary: "southwest", secondary: "west-central",
|
||||
edge: "top", share: 0.5,
|
||||
primaryNote: "south", secondaryNote: "north",
|
||||
},
|
||||
UT: {
|
||||
primary: "southwest", secondary: "northwest",
|
||||
edge: "top", share: 0.28,
|
||||
primaryNote: "excl. Salt Lake City", secondaryNote: "Salt Lake City area",
|
||||
},
|
||||
IA: {
|
||||
primary: "great-lakes", secondary: "south-central",
|
||||
edge: "bottom", share: 0.22,
|
||||
primaryNote: "most", secondaryNote: "small part",
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Tile map layout ──────────────────────────────────────────
|
||||
Each state is one square on a 12 × 7 grid, placed roughly where
|
||||
it sits on a real map. A tile grid instead of true geography:
|
||||
every state reads at the same size (Rhode Island included), it
|
||||
stays legible on a phone, and there's no map library to load.
|
||||
[column, row], both 1-based.
|
||||
|
||||
CANADA is a wide band across the top — not a state, so it's
|
||||
listed separately in MAP_BANDS below.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export 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],
|
||||
};
|
||||
|
||||
/* Wide areas that span several columns. */
|
||||
export const MAP_BANDS = {
|
||||
CANADA: { cols: [3, 10], row: 1, label: "Canada" },
|
||||
};
|
||||
|
||||
export const STATE_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",
|
||||
};
|
||||
|
||||
/* ── Chapters ─────────────────────────────────────────────────
|
||||
name what the group calls itself
|
||||
city city, or the country for international groups
|
||||
state two-letter code, "CANADA", or null
|
||||
group set this instead of `state` for a group with no map
|
||||
area (africa, uk, virtual)
|
||||
region ONLY needed in a split state (CA, NV, UT, IA) — say
|
||||
which of the two regions the chapter belongs to
|
||||
meets when it gathers
|
||||
link optional URL
|
||||
contact optional email
|
||||
|
||||
Used by the grid view and its detail panel:
|
||||
logo filename in public/chapter-logos/ (e.g. "lynnwood.svg").
|
||||
null → the card shows the chapter's initials instead.
|
||||
about a paragraph for the expanded panel
|
||||
leads who runs it
|
||||
where meeting address or platform
|
||||
started optional "Since 2021"
|
||||
───────────────────────────────────────────────────────────── */
|
||||
export const CHAPTERS = [
|
||||
{
|
||||
id: "lynnwood",
|
||||
name: "NGU Lynnwood",
|
||||
city: "Lynnwood, WA",
|
||||
state: "WA",
|
||||
meets: "2nd Sundays, 6:00pm",
|
||||
link: null,
|
||||
contact: null,
|
||||
logo: null,
|
||||
about:
|
||||
"A monthly gathering for young adults in south Snohomish County — potlucks, discussion nights, and service projects.",
|
||||
leads: "Chapter lead name",
|
||||
where: "Unity in Lynnwood",
|
||||
started: null,
|
||||
},
|
||||
{
|
||||
id: "bellingham",
|
||||
name: "NGU Bellingham",
|
||||
city: "Bellingham, WA",
|
||||
state: "WA",
|
||||
meets: "Monthly — dates vary",
|
||||
link: null,
|
||||
contact: null,
|
||||
logo: null,
|
||||
about: "Short description of what this chapter does and who it's for.",
|
||||
leads: null,
|
||||
where: null,
|
||||
started: null,
|
||||
},
|
||||
{
|
||||
id: "unity-village",
|
||||
name: "NGU Unity Village",
|
||||
city: "Unity Village, MO",
|
||||
state: "MO",
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "bay-area",
|
||||
name: "NGU Bay Area",
|
||||
city: "Oakland, CA",
|
||||
state: "CA",
|
||||
region: "west-central", // split state — northern California
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "toronto",
|
||||
name: "NGU Toronto",
|
||||
city: "Toronto, Ontario",
|
||||
state: "CANADA",
|
||||
meets: "Monthly",
|
||||
link: null,
|
||||
contact: null,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "NGU Discord",
|
||||
city: "Online",
|
||||
state: null,
|
||||
group: "virtual",
|
||||
meets: "Always open",
|
||||
link: "https://discord.com/invite/AtngzpqaX5",
|
||||
contact: null,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Lookups built from the tables above ───────────────────── */
|
||||
|
||||
export const GROUP_BY_ID = Object.fromEntries(GROUPS.map(g => [g.id, g]));
|
||||
|
||||
// "WA" → the Northwest group. Split states resolve to their primary.
|
||||
export const GROUP_BY_STATE = {
|
||||
...Object.fromEntries(GROUPS.flatMap(g => g.states.map(s => [s, g]))),
|
||||
...Object.fromEntries(
|
||||
Object.entries(SPLITS).map(([s, sp]) => [s, GROUP_BY_ID[sp.primary]])
|
||||
),
|
||||
};
|
||||
|
||||
// Both groups that touch a tile, primary first
|
||||
export const groupsForState = code => {
|
||||
const split = SPLITS[code];
|
||||
if (split) return [GROUP_BY_ID[split.primary], GROUP_BY_ID[split.secondary]];
|
||||
const g = GROUP_BY_STATE[code];
|
||||
return g ? [g] : [];
|
||||
};
|
||||
|
||||
// Which group a chapter belongs to
|
||||
export const groupOf = c =>
|
||||
GROUP_BY_ID[c.region] || GROUP_BY_ID[c.group] || GROUP_BY_STATE[c.state];
|
||||
|
||||
export const chaptersIn = groupId =>
|
||||
CHAPTERS.filter(c => groupOf(c)?.id === groupId);
|
||||
|
||||
// { WA: 2, MO: 1, ... } — how many chapters each map area has
|
||||
export const CHAPTER_COUNT_BY_STATE = CHAPTERS.reduce((acc, c) => {
|
||||
if (c.state) acc[c.state] = (acc[c.state] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/* "AK, ID, MT, OR, UT (Salt Lake City area), WA" — the states a
|
||||
region covers, split states annotated, alphabetical. Groups with
|
||||
no states (Africa, UK, Virtual) fall back to their own `note`. */
|
||||
export const statesLabel = group => {
|
||||
// split states are listed below with their annotation, not here
|
||||
const whole = group.states.filter(s => s !== "CANADA" && !SPLITS[s]);
|
||||
const shared = Object.entries(SPLITS)
|
||||
.filter(([, sp]) => sp.primary === group.id || sp.secondary === group.id)
|
||||
.map(([code, sp]) =>
|
||||
sp.primary === group.id
|
||||
? `${code} (${sp.primaryNote})`
|
||||
: `${code} (${sp.secondaryNote})`
|
||||
);
|
||||
return [...whole, ...shared].sort().join(", ");
|
||||
};
|
||||
|
||||
export const groupSubtext = group => {
|
||||
const states = statesLabel(group);
|
||||
if (states && group.note) return `${states} · ${group.note}`;
|
||||
return states || group.note || "";
|
||||
};
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export const DOMESTIC = GROUPS.filter(g => g.scope === "domestic").sort(byName);
|
||||
export const INTERNATIONAL = GROUPS.filter(g => g.scope === "international").sort(byName);
|
||||
export const VIRTUAL = GROUPS.filter(g => g.scope === "virtual");
|
||||
568
src/pages/sections/EventList-Cards.tsx
Normal file
568
src/pages/sections/EventList-Cards.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
||||
A band of event cards, as a peek carousel or a grid. Self
|
||||
contained: give it a filter and it fetches, so the same section
|
||||
appears three times on Retreats with a different `section` each
|
||||
time, and could appear on a region's page with `host` instead.
|
||||
|
||||
<EventListCards section="national" view="carousel" />
|
||||
<EventListCards host="northwest" view="grid" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
const LOGOS = Object.fromEntries(
|
||||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
|
||||
/* Last resort only. The API already falls back to the host
|
||||
organization's logo when an event doesn't name its own, so this
|
||||
fires only for an event with no host at all. */
|
||||
const DEFAULT_ORG_LOGO = null;
|
||||
|
||||
/* An <img> that removes itself if the file 404s. */
|
||||
function Logo({ file, alt = "", className }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = logoSrc(file);
|
||||
if (!src || failed) return null;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TEAL = "#138ba0"; // last-resort card color
|
||||
const CARD_BG = "#ffffff"; // sits under every card, gradient or not
|
||||
|
||||
/* ── Grid view ────────────────────────────────────────────────
|
||||
The grid fits as many columns as the screen allows, with no
|
||||
breakpoints: each card is at least CARD_MIN wide, and the
|
||||
columns share whatever space is left over.
|
||||
CARD_MIN narrowest a card may get before dropping a column
|
||||
MAX_COLS ceiling on columns, so cards don't get absurd on
|
||||
very wide monitors. GRID_MAX is derived from it.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD_MIN = "32rem";
|
||||
const MAX_COLS = 4;
|
||||
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
|
||||
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
|
||||
|
||||
/* Collapsed past-events strip: how much shows, and the downward
|
||||
fade applied while it's collapsed. */
|
||||
const PAST_PEEK = "10rem";
|
||||
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
|
||||
|
||||
/* ── Carousel sizing ──────────────────────────────────────────
|
||||
CARD the card itself
|
||||
GAP space between cards — raise to push neighbors out
|
||||
FADE_DIST how far past the card's edge neighbors fade to nothing
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD = "min(48rem, 90vw)";
|
||||
const GAP = "5rem";
|
||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
||||
const FADE_DIST = "18rem";
|
||||
|
||||
const EDGE_FADE = `linear-gradient(to right,
|
||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
||||
black calc(50% - ${HALF_SLIDE}),
|
||||
black calc(50% + ${HALF_SLIDE}),
|
||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||
|
||||
/* Card layout CSS lives in index.css under the .ev- prefix. */
|
||||
|
||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ig-icon w-6 h-6"
|
||||
style={{ "--ig-fill": `url(#${id})` }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FEDA75" />
|
||||
<stop offset="25%" stopColor="#FA7E1E" />
|
||||
<stop offset="50%" stopColor="#D62976" />
|
||||
<stop offset="75%" stopColor="#962FBF" />
|
||||
<stop offset="100%" stopColor="#4F5BD5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CARD — one component, two sizes.
|
||||
compact=false → the full card used in the carousel
|
||||
compact=true → the grid card: details left, text right, and a
|
||||
footer pinned to the bottom so every card in a
|
||||
row is the same height with aligned buttons.
|
||||
|
||||
Exported because it takes an event and nothing else: a region's
|
||||
page or a home strip can render one without the section around
|
||||
it.
|
||||
|
||||
Fields arrive pre-resolved from the API — `color` is the event's
|
||||
own or its host's, `status` is derived from the dates when it
|
||||
isn't set — so nothing here reimplements those rules.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
defaultColor = TEAL,
|
||||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||
const eventLogo = ev.event_logo;
|
||||
const links = ev.links ?? [];
|
||||
const igHandle = ev.instagram || null;
|
||||
const igUrl = igHandle
|
||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: null;
|
||||
|
||||
/* An ordered array, so a card can carry one paragraph or five
|
||||
without the component changing. */
|
||||
const descriptions = (ev.description ?? []).map((text, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`leading-relaxed ${compact ? "text-base" : ""} ${
|
||||
i > 0 ? "mt-2" : ""
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
|
||||
compact ? "ev-card" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: ev.gradient ? `${ev.gradient}, ${CARD_BG}` : CARD_BG,
|
||||
filter: past ? "saturate(0.75)" : "none",
|
||||
pointerEvents: interactive ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
|
||||
{compact ? (
|
||||
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
|
||||
<div className="ev-grid mb-2">
|
||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||
<div className="ev-info">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-xl">{ev.location_label}</p>
|
||||
)}
|
||||
</div>
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="ev-image w-full h-auto object-contain"
|
||||
/>
|
||||
<div className="ev-desc">{descriptions}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── FULL: details left, large event logo right ── */
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-2xl">{ev.location_label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-center justify-end">
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="w-full h-auto max-h-72 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{descriptions}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{links.map(item => (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.url}
|
||||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${color}` }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className="mt-auto text-center font-600 pt-8"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
This event has concluded — thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
{igHandle && (
|
||||
<a
|
||||
href={igUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
<InstagramIcon id={`ig-${ev.id}`} />
|
||||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||||
style={{ border: `1px solid ${accent}` }}
|
||||
role="group"
|
||||
aria-label="Change how events are displayed"
|
||||
>
|
||||
<button
|
||||
onClick={() => setView("carousel")}
|
||||
aria-pressed={view === "carousel"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "carousel")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="7" y="5" width="10" height="14" rx="2" />
|
||||
<path d="M3.5 8v8M20.5 8v8" />
|
||||
</svg>
|
||||
Carousel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
aria-pressed={view === "grid"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "grid")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
Grid
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION
|
||||
Fetches its own slice of the events, then draws it as a carousel
|
||||
or a grid.
|
||||
|
||||
An empty list means three different things now — still loading,
|
||||
failed, or genuinely nothing scheduled — and they read very
|
||||
differently to someone waiting, so they're distinguished rather
|
||||
than all falling through to "coming soon".
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export default function EventListCards({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
|
||||
/* Open on the first upcoming event. The list is empty on the
|
||||
first render, so this can't be a useState initialiser — it has
|
||||
to wait for the data and then run once. Clearing the guard when
|
||||
the list empties means a refetch re-seeds. */
|
||||
const seeded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (events.length === 0) {
|
||||
seeded.current = false;
|
||||
return;
|
||||
}
|
||||
if (seeded.current) return;
|
||||
seeded.current = true;
|
||||
|
||||
const first = events.findIndex(e => e.status === "upcoming");
|
||||
setIndex(first === -1 ? events.length - 1 : first);
|
||||
}, [events]);
|
||||
|
||||
// Grid view splits the list; the carousel still shows everything.
|
||||
const { upcoming, past: pastEvents } = splitByStatus(events);
|
||||
|
||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||
|
||||
// Arrows and dots follow the section accent, not the active card.
|
||||
const arrowStyle = enabled => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: enabled ? accent : "#b8c6c9",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
const notice = text => (
|
||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="mx-auto px-8 md:px-12 lg:px-16"
|
||||
style={{ maxWidth: GRID_MAX }}
|
||||
>
|
||||
<div className="skeleton" role="status" aria-label="Loading events" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && events.length === 0) {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
) : view === "grid" ? (
|
||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||
{upcoming.length > 0 && (
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
{upcoming.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pastEvents.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3
|
||||
className="text-2xl font-800 mb-6"
|
||||
style={{ color: accent, opacity: 0.85 }}
|
||||
>
|
||||
Past Events
|
||||
</h3>
|
||||
|
||||
{/* Collapsed: clipped to PAST_PEEK and faded out at the
|
||||
bottom. Expanded: full height, no mask. */}
|
||||
<div
|
||||
className="relative transition-all duration-500 ease-out"
|
||||
style={{
|
||||
maxHeight: showPast ? "none" : PAST_PEEK,
|
||||
overflow: showPast ? "visible" : "hidden",
|
||||
maskImage: showPast ? "none" : PAST_FADE,
|
||||
WebkitMaskImage: showPast ? "none" : PAST_FADE,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
aria-hidden={!showPast}
|
||||
>
|
||||
{pastEvents.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
interactive={showPast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
onClick={() => setShowPast(v => !v)}
|
||||
aria-expanded={showPast}
|
||||
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
{showPast
|
||||
? "Hide past events"
|
||||
: `See past events (${pastEvents.length})`}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 transition-transform duration-300"
|
||||
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── CAROUSEL VIEW ── */
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
||||
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
|
||||
>
|
||||
{events.map((ev, i) => {
|
||||
const active = i === index;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: SLIDE,
|
||||
padding: `0 calc(${GAP} / 2)`,
|
||||
cursor: active ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => !active && setIndex(i)}
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<Card
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
interactive={active}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous event"
|
||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index > 0)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={index === events.length - 1}
|
||||
aria-label="Next event"
|
||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index < events.length - 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{events.map((e, i) => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Go to ${e.title}`}
|
||||
className="h-2.5 rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: i === index ? "1.5rem" : "0.625rem",
|
||||
background: i === index ? accent : "#b8c6c9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
494
src/pages/sections/FeedbackForm.tsx
Normal file
494
src/pages/sections/FeedbackForm.tsx
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
WEBSITE FEEDBACK FORM
|
||||
|
||||
Posts to /api/feedback, which is the only public write on the
|
||||
site. The server is the authority on validation; the checks here
|
||||
exist to stop someone hitting a 422 they could have avoided, and
|
||||
the two sets are kept deliberately in step (MIN_MESSAGE, and the
|
||||
ids in FEEDBACK_TYPES).
|
||||
|
||||
The page and section options come from navConfig, so adding a
|
||||
page to the nav adds it to this form too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const MUTED = "#4a6b72";
|
||||
const MAX_CHARS = 1500;
|
||||
const MIN_MESSAGE = 10; // matches the server
|
||||
|
||||
// Sentinel for "this isn't about one particular page".
|
||||
const SITE_WIDE = "site";
|
||||
// Sentinel for "this page, but not one section of it".
|
||||
const WHOLE_PAGE = "";
|
||||
|
||||
// Ids must match TYPES in server/src/routes/feedback.js.
|
||||
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";
|
||||
|
||||
const errorFieldClass = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||
|
||||
const selectClass = `${fieldClass} appearance-none pr-10`;
|
||||
|
||||
function OptionalTag() {
|
||||
return (
|
||||
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
Optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Native select plus a chevron, since appearance-none strips the default one.
|
||||
function Select({ id, label, value, onChange, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-2">
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
<path
|
||||
d="M5 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
What kind of feedback is this?
|
||||
</legend>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
Pick the closest fit. It helps us route it to the right person.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEEDBACK_TYPES.map((type) => {
|
||||
const selected = value === type.id;
|
||||
return (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feedbackType"
|
||||
value={type.id}
|
||||
checked={selected}
|
||||
onChange={() => onChange(type.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#eef9fb]"
|
||||
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-[#26454c]">{type.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
|
||||
: "border-[#4a6b72]/30")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{type.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Where on the site ───────────────────────────────────────── */
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">
|
||||
Where did you run into it?
|
||||
</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave this on "Not page-specific" if it applies to the whole site.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
id="feedback-page"
|
||||
label="Page"
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
onPageChange(next);
|
||||
onSectionChange(WHOLE_PAGE); // sections belong to a page
|
||||
}}
|
||||
>
|
||||
<option value={SITE_WIDE}>Not page-specific</option>
|
||||
{PAGE_LINKS.map((p) => (
|
||||
<option key={p.path} value={p.path}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<Select
|
||||
id="feedback-section"
|
||||
label="Section of that page"
|
||||
value={section}
|
||||
onChange={onSectionChange}
|
||||
>
|
||||
<option value={WHOLE_PAGE}>The page as a whole</option>
|
||||
{sections.map((s) => (
|
||||
<option key={s.hash} value={s.hash}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable version of the picked location, for the thank-you panel.
|
||||
function describeLocation(page, section) {
|
||||
if (page === SITE_WIDE) return null;
|
||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||
(s) => s.hash === section,
|
||||
)?.label;
|
||||
return sectionLabel ? `${pageLabel} → ${sectionLabel}` : pageLabel;
|
||||
}
|
||||
|
||||
/* ── The form ────────────────────────────────────────────────── */
|
||||
|
||||
export default function FeedbackForm() {
|
||||
const [type, setType] = useState(null);
|
||||
const [page, setPage] = useState(SITE_WIDE);
|
||||
const [section, setSection] = useState(WHOLE_PAGE);
|
||||
const [message, setMessage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
// Honeypot: never shown, never filled by a person.
|
||||
const [website, setWebsite] = useState("");
|
||||
|
||||
// idle → sending → sent, or back to idle with an error to show.
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
|
||||
const sending = status === "sending";
|
||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!ready || sending) return;
|
||||
|
||||
setStatus("sending");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
await post("/feedback", {
|
||||
feedbackType: type,
|
||||
message: message.trim(),
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
pagePath: page === SITE_WIDE ? "" : page,
|
||||
sectionId: section, // server strips the leading '#'
|
||||
website,
|
||||
});
|
||||
setStatus("sent");
|
||||
} catch (error) {
|
||||
setStatus("idle");
|
||||
if (error instanceof ApiError) {
|
||||
setFieldErrors(error.fields ?? {});
|
||||
setFormError(
|
||||
error.fields
|
||||
? "Have another look at the highlighted fields."
|
||||
: error.message,
|
||||
);
|
||||
} else {
|
||||
// Network failure, offline, server down.
|
||||
setFormError("Couldn't reach the server. Try again in a moment.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setType(null);
|
||||
setPage(SITE_WIDE);
|
||||
setSection(WHOLE_PAGE);
|
||||
setMessage("");
|
||||
setName("");
|
||||
setEmail("");
|
||||
setWebsite("");
|
||||
setStatus("idle");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
if (status === "sent") {
|
||||
const where = describeLocation(page, section);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
|
||||
Thanks — we've got it
|
||||
</h3>
|
||||
{where && (
|
||||
<p className="mt-3 text-sm" style={{ color: MUTED }}>
|
||||
Filed against {where}.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
|
||||
{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."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<TypePicker value={type} onChange={setType} />
|
||||
|
||||
<div className="mt-10">
|
||||
<LocationPicker
|
||||
page={page}
|
||||
section={section}
|
||||
onPageChange={setPage}
|
||||
onSectionChange={setSection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-10">
|
||||
<label
|
||||
htmlFor="feedback-message"
|
||||
className="text-base font-semibold text-[#26454c]"
|
||||
>
|
||||
Tell us more
|
||||
</label>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
What you expected to happen, and what happened instead, is the most
|
||||
useful thing you can give us.
|
||||
</p>
|
||||
<textarea
|
||||
id="feedback-message"
|
||||
rows={7}
|
||||
maxLength={MAX_CHARS}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I was looking for the summer retreat dates and…"
|
||||
aria-invalid={Boolean(fieldErrors.message)}
|
||||
aria-describedby={fieldErrors.message ? "feedback-message-error" : undefined}
|
||||
className={`mt-4 resize-y ${fieldClass} ${
|
||||
fieldErrors.message ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<FieldError id="feedback-message-error">{fieldErrors.message}</FieldError>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-xs tabular-nums"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
{message.length} / {MAX_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional contact details */}
|
||||
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">Your details</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave these blank and your feedback comes through anonymously. Fill
|
||||
them in only if you'd like a reply — we won't add you to any list.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-name"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={Boolean(fieldErrors.email)}
|
||||
aria-describedby={fieldErrors.email ? "feedback-email-error" : undefined}
|
||||
className={`mt-2 ${fieldClass} ${
|
||||
fieldErrors.email ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<FieldError id="feedback-email-error">{fieldErrors.email}</FieldError>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honeypot. Off-screen rather than display:none, since bots
|
||||
skip hidden inputs. Never announced, never tabbable. */}
|
||||
<div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden">
|
||||
<label htmlFor="feedback-website">Website</label>
|
||||
<input
|
||||
id="feedback-website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{formError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-8 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || sending}
|
||||
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
|
||||
>
|
||||
{sending ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
{!ready && !sending && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
Choose a type and write at least a sentence to send.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
240
src/pages/sections/OrgList-Card.tsx
Normal file
240
src/pages/sections/OrgList-Card.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — CARDS
|
||||
|
||||
A grid of organization cards. No toggle: this view is the grid,
|
||||
which is why the section that uses it declares no `views` in the
|
||||
page manifest and gets no control in its heading.
|
||||
|
||||
<OrgListCards kind="partner" pageLabel="Partner page" />
|
||||
|
||||
Where the vertical list expands in place, a card links out. The
|
||||
card carries as much as fits at a glance and the organization's
|
||||
own page carries the rest, so nothing here reads a `blocks`
|
||||
array — only the short card description.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* Wide enough for a logo beside two lines of text, narrow enough
|
||||
that three fit on a laptop. The grid drops a column rather than
|
||||
squeezing below this. */
|
||||
const CARD_MIN = "20rem";
|
||||
const GRID_MAX = "88rem";
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-14 w-14 object-contain rounded-xl shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-14 w-14 rounded-xl shrink-0 flex items-center justify-center font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||
const color = org.color || accent;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Tagline first, then where they are. Partners often have one and
|
||||
// not the other, so this is the line that's most likely to say
|
||||
// something useful.
|
||||
const subtitle = org.tagline || org.location_label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 h-full flex flex-col transition-transform duration-200 hover:scale-[1.01]"
|
||||
style={{ border: `1px solid ${color}`, background: "#ffffff" }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-800 leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm mt-0.5" style={{ color: MUTED }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right rather than in the footer: the footer holds
|
||||
outbound links, and the arrow goes somewhere different
|
||||
in kind — deeper into this site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{org.description?.length > 0 && (
|
||||
<p
|
||||
className="mt-4 text-sm leading-relaxed line-clamp-4"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{org.description[0]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the footer so buttons line up across a row
|
||||
however much text each card carries. */}
|
||||
<div className="mt-auto pt-5 flex flex-wrap gap-2">
|
||||
{org.website && (
|
||||
<a
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{org.email && (
|
||||
<a
|
||||
href={`mailto:${org.email}`}
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-12">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-5" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-5 items-stretch"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`,
|
||||
}}
|
||||
>
|
||||
{orgs.map(org => (
|
||||
<OrgCard
|
||||
key={org.id}
|
||||
org={org}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListCards({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Learn more",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided grid.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block
|
||||
title={title}
|
||||
orgs={sorted}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
accent={accent}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
907
src/pages/sections/OrgList-Map.tsx
Normal file
907
src/pages/sections/OrgList-Map.tsx
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommunity } from "../../data/chapters.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — MAP
|
||||
|
||||
Organizations placed on a tile grid: the map on the left, the
|
||||
list on the right, selecting on either side filtering both.
|
||||
|
||||
Filtered to chapters, and unlike the other sections that isn't
|
||||
a prop. A map only makes sense for organizations that sit
|
||||
somewhere, and it reads regions off the same data to colour the
|
||||
tiles — so "plot partners instead" isn't a filter change, it's a
|
||||
different component. Named for the view rather than the filter
|
||||
so that stays visible.
|
||||
|
||||
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
|
||||
<RegionMap> — everything else works off the data.
|
||||
|
||||
The tile layout comes from mapGrid.js; who paints what comes
|
||||
from the API. A state and the Canada band are the same shape
|
||||
now, a tile with a span, so the map is one loop.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
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";
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
const US_TITLE = "US Unity Regions";
|
||||
const INTL_TITLE = "International Unity Regions";
|
||||
|
||||
/* ── Body content ──────────────────────────────────────────────
|
||||
A chapter's description is ordered blocks rather than one
|
||||
string, so a heading or a list added later renders instead of
|
||||
vanishing. When organization and person pages arrive this should
|
||||
move to a shared component; small enough to live here until then.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Blocks({ blocks = [], color }) {
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3" style={{ color: INK }}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return (
|
||||
<h4 key={i} className="text-lg font-800" style={{ color }}>
|
||||
{block.text}
|
||||
</h4>
|
||||
);
|
||||
case "subheading":
|
||||
return (
|
||||
<h5 key={i} className="font-700">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="pl-3 italic"
|
||||
style={{ borderLeft: `2px solid ${color}` }}
|
||||
>
|
||||
{block.text}
|
||||
</blockquote>
|
||||
);
|
||||
case "list":
|
||||
case "links":
|
||||
return (
|
||||
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
||||
{block.items.map((item, j) => (
|
||||
<li key={j}>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color }}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
{item.detail && (
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{" "}
|
||||
— {item.detail}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "divider":
|
||||
return <hr key={i} style={{ borderColor: RULE }} />;
|
||||
default:
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{block.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tile ──────────────────────────────────────────────────────
|
||||
Every region painting this tile gets its own rect. A whole tile
|
||||
is one slice with no edge; a shared tile is two, each declaring
|
||||
which edge it sits on and how much it takes. Nothing here
|
||||
subtracts, and the order the slices arrive in doesn't change
|
||||
what's drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Tile({
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
width = size,
|
||||
label,
|
||||
slices = [],
|
||||
count = 0,
|
||||
selected,
|
||||
hovered,
|
||||
setSelected,
|
||||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}) {
|
||||
if (slices.length === 0) return null;
|
||||
|
||||
const primary = slices[0];
|
||||
const ids = slices.map(s => s.regionId);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
const opacity = count ? 1 : active ? 0.5 : 0.28;
|
||||
|
||||
// Clicking a shared tile cycles through its regions and then
|
||||
// clears, so every slice is reachable without a second control.
|
||||
const cycle = () => {
|
||||
onPick?.(code);
|
||||
const at = ids.indexOf(selected);
|
||||
setSelected(at === ids.length - 1 ? null : ids[at + 1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={cycle}
|
||||
onMouseEnter={() => setHovered(primary.regionId)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
<title>
|
||||
{AREA_NAMES[code] || code} — {slices.map(s => s.name).join(" / ")}
|
||||
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
|
||||
</title>
|
||||
|
||||
<clipPath id={clipId}>
|
||||
<rect x={x} y={y} width={width} height={size} rx={14} />
|
||||
</clipPath>
|
||||
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{slices.map(slice => {
|
||||
const h = slice.edge ? size * slice.share : size;
|
||||
const top = slice.edge === "bottom" ? y + size - h : y;
|
||||
return (
|
||||
<rect
|
||||
key={slice.regionId}
|
||||
x={x}
|
||||
y={top}
|
||||
width={width}
|
||||
height={h}
|
||||
fill={slice.color}
|
||||
fillOpacity={opacity}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + size / 2 + 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
</text>
|
||||
|
||||
{count > 0 && (
|
||||
<circle
|
||||
cx={x + width - 14}
|
||||
cy={y + 14}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.9}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||
const size = TILE - PAD * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
|
||||
className="w-full h-auto"
|
||||
role="group"
|
||||
aria-label="Chapters by Unity region"
|
||||
>
|
||||
{/* States and bands are the same shape — a tile with a span —
|
||||
so this is one loop rather than two. */}
|
||||
{AREAS.map(area => (
|
||||
<Tile
|
||||
key={area.code}
|
||||
code={area.code}
|
||||
label={area.isState ? area.code : area.name}
|
||||
x={(area.col - 1) * TILE + PAD}
|
||||
y={(area.row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
width={area.span * TILE - PAD * 2}
|
||||
fontSize={area.isState ? 34 : 38}
|
||||
slices={slices[area.code] ?? []}
|
||||
count={chapterCounts[area.code] ?? 0}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === region.id || hovered === region.id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(region.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-pressed={selected === region.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${region.color}`,
|
||||
background: on ? region.color : "transparent",
|
||||
color: on ? "#ffffff" : region.color,
|
||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : region.color }}
|
||||
/>
|
||||
{region.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||
const { selected, setSelected } = props;
|
||||
|
||||
// A region is on the map if it paints a tile. West Central used
|
||||
// to need a hardcoded exception here because its states arrived
|
||||
// only through SPLITS; it has ordinary rows now, so the exception
|
||||
// is gone.
|
||||
const onMap = region => onMapIds.has(region.id);
|
||||
const us = domestic.filter(onMap);
|
||||
const intl = international.filter(onMap);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{US_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{us.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px my-4" style={{ background: RULE }} />
|
||||
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{INTL_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{intl.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="py-1.5 px-3 rounded-lg text-sm font-700 underline"
|
||||
style={{ color: FALLBACK_COLOR }}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionBlock({
|
||||
region,
|
||||
chapters,
|
||||
subtext,
|
||||
selected,
|
||||
setSelected,
|
||||
setHovered,
|
||||
indent,
|
||||
regionRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
const on = selected === region.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(on ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full flex items-baseline gap-2 text-left py-2"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
{region.name}
|
||||
</h4>
|
||||
<span className="text-sm ml-auto" style={{ color: MUTED }}>
|
||||
{chapters.length || "—"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{subtext && (
|
||||
<p className="text-sm mb-2 ml-5 leading-snug" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm ml-5 mb-4" style={{ color: MUTED }}>
|
||||
No chapters yet — interested in starting one?
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3 flex items-start gap-3"
|
||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700">{c.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{c.location_label}
|
||||
{c.meets ? ` · ${c.meets}` : ""}
|
||||
</p>
|
||||
{(c.website || c.email) && (
|
||||
<p className="text-sm mt-1 flex gap-4">
|
||||
{c.website && (
|
||||
<a
|
||||
href={c.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
{c.email && (
|
||||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orgPath(c) && (
|
||||
<ArrowLink
|
||||
to={orgPath(c)}
|
||||
label={`${c.name} — chapter page`}
|
||||
color={region.color}
|
||||
size="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Logo, or the organization's initials when there's no file. */
|
||||
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt={org.name}
|
||||
onError={() => setFailed(true)}
|
||||
className={`${size} object-contain rounded-xl shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-label={org.name}
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: open ? `${color}14` : "transparent",
|
||||
}}
|
||||
>
|
||||
<OrgLogo org={chapter} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700 leading-tight">{chapter.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{chapter.location_label}
|
||||
</p>
|
||||
{chapter.meets && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{chapter.meets}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two controls, two destinations: the chevron opens the
|
||||
detail panel in place, the arrow leaves for the chapter's
|
||||
own page. Keeping them distinct beats one control that
|
||||
does different things depending on where you click. */}
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
|
||||
className="h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{orgPath(chapter) && (
|
||||
<ArrowLink
|
||||
to={orgPath(chapter)}
|
||||
label={`${chapter.name} — chapter page`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({ chapter, region, onClose }) {
|
||||
/* "Led by" comes from affiliations rather than a text field, so
|
||||
it lists real people and stays empty until they exist. */
|
||||
const leads = (chapter.leadership ?? [])
|
||||
.map(person =>
|
||||
person.title ? `${person.display_name} (${person.title})` : person.display_name
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const rows = [
|
||||
["Region", region.name],
|
||||
["Where", chapter.venue],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", leads],
|
||||
["Since", chapter.started],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
<p style={{ color: FALLBACK_COLOR }}>{chapter.location_label}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2">
|
||||
<dt className="shrink-0" style={{ color: MUTED }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-700" style={{ color: INK }}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{orgPath(chapter) && (
|
||||
<Link
|
||||
to={orgPath(chapter)}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ background: region.color, color: "#ffffff" }}
|
||||
>
|
||||
Chapter page
|
||||
</Link>
|
||||
)}
|
||||
{chapter.website && (
|
||||
<a
|
||||
href={chapter.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Visit site
|
||||
</a>
|
||||
)}
|
||||
|
||||
{chapter.email && (
|
||||
<a
|
||||
href={`mailto:${chapter.email}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = regions
|
||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||
.filter(({ list }) => list.length > 0);
|
||||
const empty = regions.filter(region => chaptersIn(region.id).length === 0);
|
||||
const openChapter = chapters.find(c => c.id === openId) || null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ region, list }) => {
|
||||
const subtext = subtextFor(region);
|
||||
return (
|
||||
<section key={region.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</h3>
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
{subtext && (
|
||||
<p className="text-sm mb-5 ml-6" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Detail panel sits above this region's grid, and only
|
||||
when the open chapter belongs to this region. */}
|
||||
{openChapter && openChapter.region_id === region.id && (
|
||||
<ChapterDetail
|
||||
chapter={openChapter}
|
||||
region={region}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-4 items-start"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
|
||||
}}
|
||||
>
|
||||
{list.map(c => (
|
||||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={region.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{empty.length > 0 && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
No chapters yet in {empty.map(r => r.name).join(", ")} — interested in
|
||||
starting one?
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function OrgMapToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||||
style={{ border: `1px solid ${accent}` }}
|
||||
role="group"
|
||||
aria-label="Change how chapters are displayed"
|
||||
>
|
||||
{[
|
||||
["map", "Map"],
|
||||
["grid", "Grid"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
aria-pressed={view === id}
|
||||
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
regions,
|
||||
regionAreas,
|
||||
domestic,
|
||||
international,
|
||||
virtual,
|
||||
chapters,
|
||||
chaptersIn,
|
||||
slices,
|
||||
chapterCounts,
|
||||
regionsForArea,
|
||||
subtextFor,
|
||||
} = useCommunity();
|
||||
|
||||
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 regionRefs = 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(regionRefs.current[id]);
|
||||
}, [hovered, selected]);
|
||||
|
||||
// Clicking a tile jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickArea = code => {
|
||||
const chapter = chapters.find(c => c.area_code === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
}
|
||||
const region = regionsForArea(code)[0];
|
||||
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||
};
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && regions.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading chapters" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && regions.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· Chapters couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "grid") {
|
||||
return (
|
||||
<ChapterGrid
|
||||
regions={regions}
|
||||
chapters={chapters}
|
||||
chaptersIn={chaptersIn}
|
||||
subtextFor={subtextFor}
|
||||
openId={openId}
|
||||
setOpenId={setOpenId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||
|
||||
const block = (region, indent) => (
|
||||
<RegionBlock
|
||||
key={region.id}
|
||||
region={region}
|
||||
chapters={chaptersIn(region.id)}
|
||||
subtext={subtextFor(region)}
|
||||
indent={indent}
|
||||
regionRefs={regionRefs}
|
||||
chapterRefs={chapterRefs}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
|
||||
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. */
|
||||
shell(
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<RegionMap
|
||||
slices={slices}
|
||||
chapterCounts={chapterCounts}
|
||||
onPick={pickArea}
|
||||
{...shared}
|
||||
/>
|
||||
<Legend
|
||||
domestic={domestic}
|
||||
international={international}
|
||||
onMapIds={onMapIds}
|
||||
{...shared}
|
||||
/>
|
||||
<p className="text-sm mt-4" style={{ color: MUTED }}>
|
||||
A filled tile means a chapter meets there; a two-tone tile is a
|
||||
state shared by two Unity regions. Select a region to filter the
|
||||
list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{US_TITLE}
|
||||
</h3>
|
||||
{domestic.map(r => block(r, true))}
|
||||
|
||||
<h3
|
||||
className="text-xl font-800 mt-6 mb-1 pt-4"
|
||||
style={{ color: FALLBACK_COLOR, borderTop: `1px solid ${RULE}` }}
|
||||
>
|
||||
{INTL_TITLE}
|
||||
</h3>
|
||||
{international.map(r => block(r, true))}
|
||||
|
||||
<div className="mt-6 pt-4" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
{virtual.map(r => block(r, false))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { useId, useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — VERTICAL
|
||||
|
||||
A stack of organizations, each collapsed to a line and
|
||||
expandable for the rest. Every row carries two ways out: an
|
||||
arrow through to that organization's page on this site, and a
|
||||
labelled button out to its own site when it has one.
|
||||
|
||||
Nothing here is region-specific. It asks for a kind and renders
|
||||
what comes back, so the same section lists partners or chapters
|
||||
by changing one prop:
|
||||
|
||||
<OrgListVertical kind="partner" title="Our Partners" />
|
||||
|
||||
<OrgListVertical
|
||||
kind="region"
|
||||
groups={[
|
||||
{ key: "domestic", title: "US Unity Regions" },
|
||||
{ key: "international", title: "International Unity Regions" },
|
||||
]}
|
||||
groupBy={org => org.details?.scope}
|
||||
/>
|
||||
|
||||
Kind-specific extras (the areas a region covers, the chapters
|
||||
inside it) come from `details` and render only when present, so
|
||||
a partner row simply doesn't have them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* ── Row button ───────────────────────────────────────────────
|
||||
Deliberately a sibling of the summary button rather than inside
|
||||
it — a link nested in a button is invalid, and a screen reader
|
||||
announces the whole row as one confused control.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||
return (
|
||||
<As
|
||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-8 w-8 object-contain shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (org.color) {
|
||||
return (
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-8 w-8 rounded-lg shrink-0 flex items-center justify-center text-xs font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
const color = org.color || FALLBACK_COLOR;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Region extras. Absent for every other kind, and the JSX below
|
||||
// skips them rather than rendering empty headings.
|
||||
const areas = areasSentence(org.details?.areas ?? []);
|
||||
const chapterCount = (org.details?.chapters ?? []).length;
|
||||
const note = org.details?.map_note;
|
||||
|
||||
/* Collapsed, a row says who it is and nothing else — the areas
|
||||
sentence runs long and lives in the panel, where it appears
|
||||
exactly once rather than in both places. */
|
||||
const summary = org.tagline;
|
||||
|
||||
// Something has to be behind the chevron or opening it does nothing.
|
||||
const hasPanel =
|
||||
org.description?.length > 0 || areas || note || org.links?.length > 0;
|
||||
|
||||
return (
|
||||
<div className="py-3" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className="flex-1 min-w-0 flex items-center gap-3 text-left py-1"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ol-chevron h-4 w-4 shrink-0"
|
||||
style={{ color, transform: open ? "rotate(90deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<span className="min-w-0">
|
||||
<span className="block font-800 text-lg leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</span>
|
||||
{summary && (
|
||||
<span className="block text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{chapterCount > 0 && (
|
||||
<span
|
||||
className="ml-auto text-sm shrink-0"
|
||||
style={{ color: MUTED }}
|
||||
title={`${chapterCount} chapter${chapterCount > 1 ? "s" : ""}`}
|
||||
>
|
||||
{chapterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{org.website && (
|
||||
<RowButton
|
||||
as="a"
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</RowButton>
|
||||
)}
|
||||
|
||||
{/* Last in the row, so the arrow lines up down the right
|
||||
edge whether or not a row has an outbound site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 0fr → 1fr animates to the panel's real height, so nobody
|
||||
has to guess a max-height that's wrong the moment a
|
||||
description grows. */}
|
||||
<div
|
||||
id={panelId}
|
||||
className="ol-panel"
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pt-2 pb-3 pl-10 pr-2 flex flex-col gap-3">
|
||||
{org.description?.map((text, i) => (
|
||||
<p key={i} className="leading-relaxed" style={{ color: INK }}>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{areas && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
<span className="font-700">Covers</span> {areas}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{note && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasPanel && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
More about this region soon.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{org.links?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{org.links.map(link => (
|
||||
<RowButton
|
||||
key={link.url}
|
||||
as="a"
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{link.label}
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{orgs.map(org => (
|
||||
<OrgRow key={org.id} org={org} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListVertical({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Region page",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided list.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block title={title} orgs={sorted} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,15 @@ export default defineConfig({
|
|||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:5173",
|
||||
changeOrigin: false,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type SiteConfiguration = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue