diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index 79d6c34..732d154 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -249,6 +249,12 @@ export function normalizeId(entity, id) { } export function createRow(db, entity, payload) { + // A singleton's one row comes from its migration. There is no + // second one to create, and the CHECK on its id would refuse it. + if (entity.singleton) { + throw new HttpError(405, "There is only one of these; edit it instead."); + } + // idKind "auto": the table assigns the id, so there is nothing to // validate, nothing to check for collisions, and nothing for the // client to have sent. Timeline entries use this — they have no @@ -366,6 +372,12 @@ export function updateRow(db, entity, rawId, payload) { } export function deleteRow(db, entity, rawId) { + // Deleting a singleton would leave the page it drives with nothing + // to read, and the admin with no way to make another. + if (entity.singleton) { + throw new HttpError(405, "This can't be deleted, only edited."); + } + const id = normalizeId(entity, rawId); const result = wrapDbErrors(() => db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id), diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index 0eb4b4a..e457bb1 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -13,6 +13,8 @@ value (organizations.kind decides whether a regions or chapters row should exist) children ordered collections, replaced wholesale on save + singleton the one id this entity ever has; the engine + refuses create and delete (see front_page) Replacing children wholesale is only safe because nothing has a foreign key INTO these tables. That is the dividing line, and @@ -650,7 +652,133 @@ const timeline = { ], }; -export const ENTITIES = { organizations, events, people, teams, awards, timeline }; +/* ── Front page ────────────────────────────────────────────────── + + A singleton: one row, id 'home', created by migration 017 and + never by the admin. `singleton` tells the engine to refuse create + and delete, and the CHECK on front_page.id is what makes a second + row impossible even without it. + + Every collection here is owned by page_id and replaced wholesale. + That is safe for the same reason it is for links and blocks — + nothing has a foreign key into these tables — and paths carry + their actions as a nested collection, the shape content blocks + and their items already use. */ + +const frontPage = { + key: "front_page", + table: "front_page", + idColumn: "id", + idKind: "slug", + singleton: "home", + concurrency: "updated_at", + + list: { + columns: ["id", "headline", "hero_mode", "updated_at"], + filters: [], + search: [], + order: "id", + }, + + columns: [ + enumeration("hero_mode", ["brand", "photos", "livestream"]), + text("eyebrow"), + text("headline"), + text("subhead"), + text("primary_label"), + text("primary_url"), + text("secondary_label"), + text("secondary_url"), + int("slide_seconds"), + text("livestream_url"), + text("livestream_title"), + text("countdown_event_id"), + ], + + children: [ + { + key: "slides", + table: "front_page_slides", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("media", { required: true }), + text("alt"), + text("caption"), + text("link_url"), + ], + }, + { + key: "sections", + table: "front_page_sections", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + enumeration( + "section", + ["countdown", "retreats", "calendar", "stats", "timeline", "connect"], + { required: true }, + ), + text("title"), + text("blurb"), + bool("is_hidden"), + ], + }, + { + key: "stats", + table: "front_page_stats", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + enumeration("source", [ + "manual", + "years_since", + "regions", + "chapters", + "partners", + "events_held", + "retreats_held", + "people", + "awards_given", + ]), + text("value"), + text("suffix"), + text("note"), + ], + }, + { + key: "paths", + table: "front_page_paths", + owner: { column: "page_id" }, + order: "sort_order", + columns: [text("label", { required: true }), text("icon"), text("blurb")], + children: [ + { + key: "actions", + table: "front_page_path_actions", + owner: { column: "path_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + text("description"), + text("url", { required: true }), + ], + }, + ], + }, + ], +}; + +export const ENTITIES = { + organizations, + events, + people, + teams, + awards, + timeline, + front_page: frontPage, +}; /* ── Options for the form's select inputs ────────────────────── */ diff --git a/server/src/index.js b/server/src/index.js index 172e523..6eab6ca 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -17,6 +17,7 @@ import { rateLimit } from "./rateLimit.js"; import content from "./routes/content.js"; import people from "./routes/people.js"; import history from "./routes/history.js"; +import home from "./routes/home.js"; import feedback from "./routes/feedback.js"; import auth from "./routes/auth.js"; import admin from "./routes/admin.js"; @@ -56,6 +57,7 @@ app.get("/api/health", (c) => app.route("/api", content); app.route("/api", people); app.route("/api", history); +app.route("/api", home); // Tighter limit on the write path than anything else gets. app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql new file mode 100644 index 0000000..7be2891 --- /dev/null +++ b/server/src/migrations/017_front_page.sql @@ -0,0 +1,189 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE +-- +-- The home page's editable half. One row in front_page — the CHECK +-- on id makes a second one impossible — and ordered collections +-- hanging off it, each replaced wholesale on save the way every +-- other child collection is. Nothing outside this file has a +-- foreign key into any of them, which is what makes that safe. +-- +-- front_page the hero: its words, its buttons, and +-- which mode it's in +-- front_page_slides photos the hero cycles through in +-- 'photos' mode +-- front_page_sections which bands the page draws, in what +-- order, under what heading +-- front_page_stats the numbers band; each one typed in or +-- counted from the database +-- front_page_paths the connect section's "I want to…" +-- choices, each with its actions +-- front_page_path_actions +-- +-- What stays in code: how each section looks, and the list of +-- section keys. A section is a component, so the CHECK on +-- front_page_sections.section is the list of components that +-- exist; a row can reorder, retitle or hide one, never invent one. +-- +-- hero_mode is switched by hand. 'livestream' shows the embed with +-- a LIVE badge until someone switches it back — no schedule, so no +-- guessing whose timezone a start time was typed in. +-- +-- countdown_event_id pins the countdown to one event. Null counts +-- down to the next upcoming published event, which is what it +-- should do almost always. +-- +-- Stats: source says where the number comes from. 'manual' prints +-- value as typed. 'years_since' reads value as a year and counts up +-- from it. Everything else is a COUNT the API runs, so the band +-- never goes stale. Adding a source is this CHECK, the enum in both +-- descriptor halves, and the query in routes/home.js. +-- +-- The seed is the page as it ships: every section, the stats that +-- need no typing, and the Church Center forms that were hardcoded +-- on the old home page, sorted into paths. +-- +-- The updated_at trigger is in 018, on its own, so no statement +-- here sits after a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page ( + id TEXT PRIMARY KEY CHECK (id = 'home'), + + hero_mode TEXT NOT NULL DEFAULT 'brand' + CHECK (hero_mode IN ('brand', 'photos', 'livestream')), + eyebrow TEXT, + headline TEXT NOT NULL DEFAULT 'Next Generation of Unity', + subhead TEXT, + primary_label TEXT, + primary_url TEXT, + secondary_label TEXT, + secondary_url TEXT, + + slide_seconds INTEGER NOT NULL DEFAULT 7 + CHECK (slide_seconds BETWEEN 3 AND 60), + + livestream_url TEXT, + livestream_title TEXT, + + countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL, + + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE TABLE front_page_slides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + media TEXT NOT NULL, -- filename in public/front-page/, or a URL + alt TEXT, + caption TEXT, + link_url TEXT +) STRICT; + +CREATE TABLE front_page_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')), + title TEXT, -- null → the section's own heading + blurb TEXT, + -- Hidden rather than visible, so a freshly added row with nothing + -- ticked is still a blank row the engine can drop. + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +CREATE TABLE front_page_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'years_since', 'regions', 'chapters', + 'partners', 'events_held', 'retreats_held', + 'people', 'awards_given')), + value TEXT, + suffix TEXT, -- '+', 'k', ' states' + note TEXT +) STRICT; + +CREATE TABLE front_page_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, -- 'Attend' + icon TEXT, -- one emoji + blurb TEXT +) STRICT; + +CREATE TABLE front_page_path_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + description TEXT, + url TEXT NOT NULL +) STRICT; + +CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order); + +-- ── Seed ───────────────────────────────────────────────────────── + +INSERT INTO front_page + (id, eyebrow, headline, subhead, + primary_label, primary_url, secondary_label, secondary_url) +VALUES + ('home', + 'Young adults of the Unity movement', + 'Next Generation of Unity', + 'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.', + 'Find a retreat', '/retreats', + 'Find your way in', '#connect'); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES + ('home', 0, 'countdown', NULL, NULL), + ('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'), + ('home', 2, 'stats', 'NGU by the numbers', NULL), + ('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), + ('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.'); + +INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES + ('home', 0, 'Regions', 'regions'), + ('home', 1, 'Chapters', 'chapters'), + ('home', 2, 'Retreats held', 'retreats_held'), + ('home', 3, 'Awards given', 'awards_given'); + +INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES + ('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'), + ('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'), + ('home', 2, 'Belong', '🌱', 'Make NGU your community.'), + ('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.'); + +INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url) +SELECT p.id, a.sort_order, a.label, a.description, a.url + FROM front_page_paths p + JOIN ( + SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label, + 'National, regional and partner gatherings.' AS description, + '/retreats' AS url + UNION ALL SELECT 'Attend', 1, 'NGU calendar', + 'Everything on the schedule, in one place.', + 'https://ngu.churchcenter.com/calendar?view=gallery' + UNION ALL SELECT 'Serve', 0, 'Volunteer', + 'Lend a hand at a retreat or event.', + 'https://ngu.churchcenter.com/people/forms/1176908' + UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory', + 'Join our network of speakers, musicians and facilitators.', + 'https://ngu.churchcenter.com/people/forms/1173181' + UNION ALL SELECT 'Belong', 0, 'Become a member', + 'Join the NGU community officially.', + 'https://ngu.churchcenter.com/people/forms/1135816' + UNION ALL SELECT 'Belong', 1, 'Find your region', + 'Chapters and regions across the country.', + '/community' + UNION ALL SELECT 'Partner', 0, 'Affiliation form', + 'Affiliate your ministry or spiritual organization with NGU.', + 'https://ngu.churchcenter.com/people/forms/1135750' + ) a ON a.path = p.label + WHERE p.page_id = 'home'; diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql new file mode 100644 index 0000000..ce48564 --- /dev/null +++ b/server/src/migrations/018_front_page_touch.sql @@ -0,0 +1,16 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE updated_at +-- +-- Same rule as the other touch triggers in 002: an UPDATE that +-- doesn't set updated_at itself gets it set, which is what the +-- admin engine's optimistic concurrency compares against. On its +-- own because the migration runner may drop anything that follows +-- a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TRIGGER front_page_touch +AFTER UPDATE ON front_page +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id; +END; diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql new file mode 100644 index 0000000..a6987b5 --- /dev/null +++ b/server/src/migrations/019_front_page_calendar.sql @@ -0,0 +1,61 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE: calendar band +-- +-- Adds 'calendar' to the sections the front page can draw. The key +-- is a CHECK, and SQLite can't alter a CHECK in place, so the table +-- is rebuilt: new table, copy, drop, rename. +-- +-- No PRAGMA foreign_keys dance. front_page_sections only points out +-- (at front_page); nothing points in, so dropping the old table +-- cascades into nothing, and the copy keeps every page_id valid. +-- +-- The new band is inserted straight after the retreats carousel, +-- where "what's on" reads naturally, by shifting everything below it +-- down one. If retreats was removed on this box, it goes last. +-- +-- Adding another section later is the same three steps: this CHECK, +-- the enum in both descriptor halves, and SECTIONS in Home.tsx. +-- +-- No BEGIN...END in this file. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page_sections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats', + 'timeline', 'connect')), + title TEXT, + blurb TEXT, + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden) +SELECT id, page_id, sort_order, section, title, blurb, is_hidden + FROM front_page_sections; + +DROP TABLE front_page_sections; + +ALTER TABLE front_page_sections_new RENAME TO front_page_sections; + +UPDATE front_page_sections + SET sort_order = sort_order + 1 + WHERE page_id = 'home' + AND sort_order > COALESCE( + (SELECT sort_order FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home')); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) +SELECT 'home', + COALESCE( + (SELECT sort_order + 1 FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections + WHERE page_id = 'home')), + 'calendar', + 'What''s on', + 'Every gathering, class and meeting in one place.' + WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home'); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index 02a860e..2138905 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -47,7 +47,14 @@ import { Hono } from "hono"; -import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; +import { + asBool, + loadBlocks, + loadLinks, + paragraphs, + shapeSeries, + splitLinks, +} from "../shape.js"; const content = new Hono(); @@ -108,25 +115,6 @@ function shapeHost(row) { }; } -/* The repeating schedule, or null for a one-off. Weekdays collapse - from seven flags to a list of the ticked ones, Sunday first; an - empty list means "starts_on's weekday", which the client resolves - since it already holds starts_on. Occurrences are not sent — they - are derived, and the client derives them against its own today. */ -const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; - -function shapeSeries(row) { - if (!asBool(row.is_series)) return null; - return { - frequency: row.series_frequency, - interval: row.series_interval, - weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])), - start_time: row.series_start_time, - end_time: row.series_end_time, - count: row.series_count, - }; -} - function shapeEvent(row, links, cardBlocks, hosts = []) { const { actions, instagram } = splitLinks(links); diff --git a/server/src/routes/home.js b/server/src/routes/home.js new file mode 100644 index 0000000..bbf14c4 --- /dev/null +++ b/server/src/routes/home.js @@ -0,0 +1,186 @@ +/* ═══════════════════════════════════════════════════════════════ + FRONT PAGE ROUTE — read-only, mounted under /api + + GET /front-page the home page's configuration, resolved + + Everything the admin's Front page editor holds, shaped for the + page: hidden sections dropped, stats counted, paths carrying + their actions, and the countdown's event looked up. + + The retreats carousel and the timeline rail are not in here. + They fetch /events and /history themselves, as they do on their + own pages, so the rules for which events and entries are public + live in one place each. This route only says whether those bands + appear and under what heading. + + ── Stats ── + A stat's source picks a query from STAT_QUERIES. Each counts + exactly what the matching public page shows: published rows, and + for awards only public citations to published people. A count + that disagreed with the page it summarises would be worse than + none. 'manual' and 'years_since' read the row's own value. + + ── Countdown ── + The pinned event if it is still published and not over; + otherwise the next published, non-cancelled event that hasn't + ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today, + so a running series with a start date in the past still counts. + The client works out the next meeting of a series from `series`. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { asBool, shapeSeries } from "../shape.js"; + +const home = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const PAGE_ID = "home"; + +const STAT_QUERIES = { + regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`, + chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`, + partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`, + events_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past'`, + retreats_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past' + AND event_type = 'retreat'`, + people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`, + awards_given: `SELECT COUNT(*) AS n + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + WHERE pa.is_public = 1`, +}; + +/* The number as a string, or null when there's nothing to print — + a manual stat nobody filled in, or a year that isn't one. */ +function statValue(db, row) { + if (row.source === "manual") return row.value || null; + + if (row.source === "years_since") { + const year = Number.parseInt(row.value ?? "", 10); + if (!Number.isInteger(year)) return null; + return String(Math.max(0, new Date().getFullYear() - year)); + } + + const sql = STAT_QUERIES[row.source]; + return sql ? String(db.prepare(sql).get().n) : null; +} + +function shapeCountdown(row) { + if (!row) return null; + return { + id: row.id, + title: row.title, + theme: row.theme, + starts_on: row.starts_on, + ends_on: row.ends_on, + date_label: row.date_label, + location_label: row.location_label, + is_online: asBool(row.is_online), + color: row.effective_color, + event_logo: row.event_logo, + series: shapeSeries(row), + }; +} + +home.get("/front-page", (c) => { + const db = c.get("db"); + + const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); + + // Migration 017 creates the row and the engine refuses to delete + // it, so this is a database that hasn't been migrated. Say so. + if (!page) return c.json({ error: "The front page hasn't been set up." }, 500); + + const byOrder = (table) => + db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID); + + const sections = byOrder("front_page_sections") + .filter((row) => !asBool(row.is_hidden)) + .map((row) => ({ section: row.section, title: row.title, blurb: row.blurb })); + + const slides = byOrder("front_page_slides").map((row) => ({ + media: row.media, + alt: row.alt, + caption: row.caption, + link_url: row.link_url, + })); + + const stats = byOrder("front_page_stats") + .map((row) => ({ + label: row.label, + value: statValue(db, row), + suffix: row.suffix, + note: row.note, + })) + .filter((stat) => stat.value !== null); + + const actions = db.prepare( + `SELECT label, description, url FROM front_page_path_actions + WHERE path_id = ? ORDER BY sort_order`, + ); + const paths = byOrder("front_page_paths") + .map((row) => ({ + label: row.label, + icon: row.icon, + blurb: row.blurb, + actions: actions.all(row.id), + })) + // A path with nothing to do is a dead tab. + .filter((path) => path.actions.length > 0); + + const notOver = `is_published = 1 + AND effective_status != 'cancelled' + AND COALESCE(ends_on, starts_on) >= date('now')`; + + const pinned = page.countdown_event_id + ? db + .prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`) + .get(page.countdown_event_id) + : null; + + const next = + pinned ?? + db + .prepare( + `SELECT * FROM v_events + WHERE ${notOver} + ORDER BY starts_on, sort_order + LIMIT 1`, + ) + .get(); + + return json(c, { + front_page: { + hero: { + mode: page.hero_mode, + eyebrow: page.eyebrow, + headline: page.headline, + subhead: page.subhead, + primary: page.primary_label && page.primary_url + ? { label: page.primary_label, url: page.primary_url } + : null, + secondary: page.secondary_label && page.secondary_url + ? { label: page.secondary_label, url: page.secondary_url } + : null, + slide_seconds: page.slide_seconds, + slides, + livestream: page.livestream_url + ? { url: page.livestream_url, title: page.livestream_title } + : null, + }, + sections, + stats, + paths, + countdown: shapeCountdown(next), + }, + }); +}); + +export default home; diff --git a/server/src/routes/people.js b/server/src/routes/people.js index e89ebca..dbc31d9 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,6 +3,7 @@ GET /teams/:id/people current public members of a team GET /people?ids=a,b,c named people, any order + GET /people/:id one person's page The team route reads v_org_leadership, which already decides who counts as current and public — affiliation still open, marked @@ -20,7 +21,7 @@ import { Hono } from "hono"; -import { asBool } from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const people = new Hono(); @@ -138,4 +139,161 @@ people.get("/people", (c) => { return json(c, { people: rows.map(shapePerson) }); }); +/* ── One person's page ───────────────────────────────────────── + Everything public that points at this person, each list with + the same visibility rules its own page applies: a role needs a + public affiliation and a published organization, an event must + be published, an award must be published and the citation + public. A hidden team drops its name rather than the role — + the seat is still real, it just has no page to link to. + + Roles are current and past. v_org_leadership only knows + current, which is what a roster wants and not what a person's + record does, so this reads affiliations directly. + + Events merge two tables: event_people (who was billed, and as + what) and event_hosts (who ran it). One person can be both at + one event, so they collapse to one row carrying every role. + ───────────────────────────────────────────────────────────── */ + +people.get("/people/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name, + o.kind AS primary_org_kind + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 + WHERE p.id = ? AND p.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such person" }, 404); + + const links = loadLinks(db, "person", [id]).get(id) ?? []; + const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; + const { actions, socials, website, instagram } = splitLinks(links); + + // Current first, then most recently ended. Within each, the same + // order a roster uses: owner, then the affiliation's sort_order. + const roles = db + .prepare( + `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, + o.id AS org_id, o.name AS org_name, o.kind AS org_kind, + t.id AS team_id, t.name AS team_name + FROM affiliations a + JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 + WHERE a.person_id = ? AND a.is_public = 1 + ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, + a.is_owner DESC, a.sort_order, o.sort_order`, + ) + .all(id) + .map((r) => ({ + title: r.title, + role: r.role, + is_owner: asBool(r.is_owner), + started_on: r.started_on, + ended_on: r.ended_on, + org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, + team: r.team_id ? { id: r.team_id, name: r.team_name } : null, + })); + + const eventRows = db + .prepare( + `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, + e.effective_status AS status, x.role, x.title AS billing + FROM ( + SELECT event_id, role, title, sort_order + FROM event_people + WHERE person_id = ? AND is_public = 1 + UNION ALL + SELECT event_id, 'host', NULL, -1 + FROM event_hosts + WHERE person_id = ? + ) x + JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 + ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + export default people; diff --git a/server/src/shape.js b/server/src/shape.js index 797db14..246d014 100644 --- a/server/src/shape.js +++ b/server/src/shape.js @@ -144,4 +144,26 @@ export function splitLinks(links = []) { }; } +/* ── Event series ────────────────────────────────────────────── + The repeating schedule, or null for a one-off. Weekdays + collapse from seven flags to a list of the ticked ones, Sunday + first; an empty list means "starts_on's weekday", which the + client resolves since it already holds starts_on. Occurrences + are not sent — they are derived, and the client derives them + against its own today. Shared by /events and /front-page. + ───────────────────────────────────────────────────────────── */ +const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +export function shapeSeries(row) { + if (!asBool(row.is_series)) return null; + return { + frequency: row.series_frequency, + interval: row.series_interval, + weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])), + start_time: row.series_start_time, + end_time: row.series_end_time, + count: row.series_count, + }; +} + export { asBool }; diff --git a/src/App.tsx b/src/App.tsx index 9b7efd4..ff85789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import EventDetail from './pages/EventDetail.tsx' import OrganizationDetail from './pages/OrganizationDetail.tsx' import TeamDetail from './pages/TeamDetail.tsx' import AwardDetail from './pages/AwardDetail.tsx' +import PersonDetail from './pages/PersonDetail.tsx' /*Admin Pages*/ import AdminLayout from "./pages/admin/AdminLayout.tsx"; @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/DoveMark.tsx b/src/components/DoveMark.tsx new file mode 100644 index 0000000..675a5a7 --- /dev/null +++ b/src/components/DoveMark.tsx @@ -0,0 +1,30 @@ +/* ═══════════════════════════════════════════════════════════════ + DOVE MARK + + NGU's dove, lifted from the original home page. The path and its + two transforms are the drawing as exported, untouched; only the + wrapper changed — sized by the caller, coloured by currentColor, + and hidden from screen readers since it's decoration wherever + it appears. + + Renders as an element, so it nests inside another SVG as + well as in HTML: pass x, y, width and height to place it in a + parent viewBox. + ═══════════════════════════════════════════════════════════════ */ + +import type { SVGProps } from 'react' + +const PATH = + 'm 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z' + +export default function DoveMark(props: SVGProps) { + return ( + + ) +} diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index 2f70d5d..ecff0d2 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,10 +123,16 @@ text-align: inherit; } -.pl__tile--button { +.pl__tile--button, +.pl__tile--link { cursor: pointer; } +.pl__tile--link { + color: inherit; + text-decoration: none; +} + .pl__frame { position: relative; display: flex; @@ -150,16 +156,20 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:hover .pl__frame, +.pl__tile--link:focus-visible .pl__frame { transform: translateY(-2px); box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); } -.pl__tile--button:focus-visible { +.pl__tile--button:focus-visible, +.pl__tile--link:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -311,6 +321,20 @@ max-width: 62ch; } +.pl__profile { + display: inline-block; + margin-top: 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--pl-accent); + text-decoration: none; +} + +.pl__profile:hover, +.pl__profile:focus-visible { + text-decoration: underline; +} + .pl__empty { margin: 0; font-size: 0.9375rem; diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index bd738a9..62dcf4f 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -8,7 +8,10 @@ import { type HTMLAttributes, } from "react"; +import { Link } from "react-router-dom"; + import { get } from "../lib/api.js"; +import { isBadId, personHref } from "../lib/hrefs.ts"; import "./PeopleTiles.css"; /** @@ -43,6 +46,13 @@ import "./PeopleTiles.css"; * * Field names follow the API (is_owner, location_label), so a row * from /api/teams/:id/people drops in unchanged. + * + * Profiles + * A person with a string id is taken to be a people row and links + * to /people/:id. A tile with nothing to expand is that link; an + * expandable one stays the button that opens its panel — a link + * can't sit inside a button — and the panel carries the link + * instead. A hand-written entry with no id links nowhere. */ export interface Person { @@ -551,7 +561,14 @@ function Tile({ ); if (!expandable) { - return
{content}
; + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); } return ( @@ -610,6 +627,7 @@ function DetailPanel({ const title = titleOf(person); const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; const tint = person.accent || group?.accent; + const profile = profileHref(person); return (
))} + + {profile && ( + + View full profile → + + )}
); } @@ -693,6 +717,12 @@ function Chevron() { /* ── Helpers ─────────────────────────────────────────────────── */ +/* A string id is a people slug; a numeric or missing one is a + hand-written entry with no page behind it. */ +function profileHref(person: Person): string | null { + return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null; +} + function keyFor(group: PeopleGroup, person: Person, index: number): string { return `${group.id}:${person.id ?? person.name ?? index}`; } diff --git a/src/data/eventData.d.ts b/src/data/eventData.d.ts index 1542ac6..e3d999b 100644 --- a/src/data/eventData.d.ts +++ b/src/data/eventData.d.ts @@ -2,7 +2,7 @@ server/src/routes/content.js sends them. */ import type { EventType } from "../lib/eventTypes.ts"; -import type { EventListItem } from "../lib/useContent.ts"; +import type { EventListItem, EventSection } from "../lib/useContent.ts"; export type EventFilter = { section?: string; @@ -13,6 +13,8 @@ export type EventFilter = { export declare function useEvents(filter?: EventFilter): { events: EventListItem[]; + /** event_sections, in scope order. Empty until loaded. */ + sections: EventSection[]; loading: boolean; error: Error | null; }; diff --git a/src/data/eventData.js b/src/data/eventData.js index 648d7ff..1aa242d 100644 --- a/src/data/eventData.js +++ b/src/data/eventData.js @@ -18,6 +18,13 @@ belongs to, the type is what kind of gathering it is. A regional class matches both { section: "regional" } and { type: "class" }. + The event_sections rows (the scope list) come back alongside, for + anything that offers a scope filter. + + No fallback. An empty list on a failed request would read as + "nothing scheduled" when the truth is "the server is down", so + the error comes back and each caller says so. + Filtering here rather than in the query keeps the endpoint to one cached response. At a few dozen events that's the right trade; if the list ever runs to hundreds, move the filters into @@ -28,12 +35,15 @@ import { useMemo } from "react"; import { useResource } from "../lib/useResource.js"; -const EMPTY = { events: [] }; +/* One shared empty list, so a memo keyed on `sections` doesn't + restart on every render before the data arrives. */ +const NO_SECTIONS = []; export function useEvents({ section, host, status, type } = {}) { - const { data, error, loading } = useResource("/events", { fallback: EMPTY }); + const { data, error, loading } = useResource("/events"); const all = data?.events; + const sections = data?.sections ?? NO_SECTIONS; /* An array prop is a new identity on every render, which would restart the memo each time. Joining it gives the dependency @@ -55,7 +65,7 @@ export function useEvents({ section, host, status, type } = {}) { return list; }, [all, section, host, status, typeKey]); - return { events, loading, error }; + return { events, sections, loading, error }; } /* Past and upcoming, split. `status` arrives already resolved — the diff --git a/src/lib/adminSchema.d.ts b/src/lib/adminSchema.d.ts index 39658f8..d6344ae 100644 --- a/src/lib/adminSchema.d.ts +++ b/src/lib/adminSchema.d.ts @@ -94,6 +94,9 @@ export type EntitySpec = { idLabel: string; /** "auto": the table assigns the id, so the form shows it rather than asking. */ idKind?: "auto"; + /** The one id a singleton entity has. The list opens it directly, + * and the editor offers no slug, back link or delete. */ + singleton?: string; /** Field(s) the slug is composed from. Absent when idKind is "auto". */ slugFrom?: string | string[]; titleFrom?: string; @@ -108,7 +111,8 @@ export type AdminEntityKey = | "people" | "teams" | "awards" - | "timeline"; + | "timeline" + | "front_page"; /* Indexed by route param as often as by name, so any other string reads as possibly missing. */ diff --git a/src/lib/adminSchema.js b/src/lib/adminSchema.js index ae29611..0ab6fba 100644 --- a/src/lib/adminSchema.js +++ b/src/lib/adminSchema.js @@ -941,7 +941,202 @@ const timeline = { ], }; -export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline }; +/* ── Front page ────────────────────────────────────────────────── + + One record (see the singleton note in server/src/admin-schema.js). + The groups are the hero; the collections below are the rest of + the page. Photos and the livestream are editable whatever the + mode, so either can be ready before the switch is flipped. + + Section keys and stat sources are the CHECK lists in migrations + 017 and 019. The labels here are what the admin reads; the values are + what the page and the API key on. */ + +const FRONT_PAGE_SECTIONS = [ + ["countdown", "Countdown to the next event"], + ["retreats", "National Retreats carousel"], + ["calendar", "Event calendar"], + ["stats", "Numbers"], + ["timeline", "Featured timeline"], + ["connect", "Find your way in"], +]; + +const STAT_SOURCES = [ + ["manual", "Typed in — shows Value"], + ["years_since", "Years since — Value is the year"], + ["regions", "Count: published regions"], + ["chapters", "Count: published chapters"], + ["partners", "Count: published partners"], + ["events_held", "Count: past events"], + ["retreats_held", "Count: past retreats"], + ["people", "Count: published people"], + ["awards_given", "Count: public awards given"], +]; + +const frontPage = { + key: "front_page", + label: "Front page", + singular: "front page", + idLabel: "Page", + singleton: "home", + + list: { columns: [], filters: [] }, + + groups: [ + { + legend: "Hero", + note: "The first thing a visitor sees. Blank buttons don't render.", + fields: [ + { + path: "hero_mode", + label: "Mode", + widget: "select", + options: [ + ["brand", "Brand — animated colour, no media"], + ["photos", "Photos — cycles through the photos below"], + ["livestream", "Livestream — embeds the stream with a LIVE badge"], + ], + blankLabel: "— brand —", + help: "Switch to Livestream when you go live, and back when you're done", + }, + { path: "eyebrow", label: "Eyebrow", help: "Small line above the headline" }, + { path: "headline", label: "Headline", full: true }, + { path: "subhead", label: "Subhead", widget: "textarea", full: true }, + { path: "primary_label", label: "Main button label" }, + { path: "primary_url", label: "Main button link", help: "/retreats, #connect or a full URL" }, + { path: "secondary_label", label: "Second button label" }, + { path: "secondary_url", label: "Second button link" }, + ], + }, + { + legend: "Photos and livestream", + fields: [ + { + path: "slide_seconds", + label: "Seconds per photo", + widget: "number", + help: "Photos mode. 3 to 60", + }, + { + path: "livestream_url", + label: "Livestream link", + full: true, + help: "A YouTube, Facebook or Vimeo link, or any embed URL", + }, + { path: "livestream_title", label: "Livestream title", full: true }, + ], + }, + { + legend: "Countdown", + fields: [ + { + path: "countdown_event_id", + label: "Count down to", + widget: "select", + optionsFrom: "events", + blankLabel: "— the next upcoming event —", + help: "Leave blank almost always. A pinned event that's over falls back to the next one", + }, + ], + }, + ], + + children: [ + { + key: "sections", + label: "Page sections", + note: "Drag to reorder. Each section can appear once; leave one out to drop it.", + addLabel: "Add section", + title: (row) => + FRONT_PAGE_SECTIONS.find(([key]) => key === row.section)?.[1] ?? "New section", + blank: { section: "", title: "", blurb: "" }, + fields: [ + { + path: "section", + label: "Section", + widget: "select", + options: FRONT_PAGE_SECTIONS, + required: true, + }, + { path: "title", label: "Heading", help: "Blank for the section's own" }, + { path: "blurb", label: "Blurb", full: true }, + { path: "is_hidden", label: "Hidden", widget: "checkbox", help: "Hide for now" }, + ], + }, + { + key: "slides", + label: "Hero photos", + note: "Shown in Photos mode, in this order.", + addLabel: "Add photo", + title: (row) => row.caption || row.media || "New photo", + blank: { media: "", alt: "", caption: "" }, + fields: [ + { path: "media", label: "Photo", required: true, help: "Filename in public/front-page/, or a URL" }, + { path: "alt", label: "Description", help: "For screen readers" }, + { path: "caption", label: "Caption", full: true }, + { path: "link_url", label: "Link", help: "Optional; makes the caption a link" }, + ], + }, + { + key: "stats", + label: "Numbers", + note: "Counts update themselves. A typed-in number with no value is left out.", + addLabel: "Add number", + title: (row) => row.label || "New number", + blank: { label: "", source: "", value: "" }, + fields: [ + { path: "label", label: "Label", required: true }, + { + path: "source", + label: "Where it comes from", + widget: "select", + options: STAT_SOURCES, + blankLabel: "— typed in —", + }, + { path: "value", label: "Value", help: "Typed in: the number. Years since: the year" }, + { path: "suffix", label: "Suffix", help: "+, k, % …" }, + { path: "note", label: "Note", full: true }, + ], + }, + { + key: "paths", + label: "Find your way in", + note: "Each path is a choice a visitor can pick; its actions appear when they do.", + addLabel: "Add path", + title: (row) => [row.icon, row.label].filter(Boolean).join(" ") || "New path", + blank: { label: "", icon: "", blurb: "" }, + fields: [ + { path: "label", label: "Label", required: true, help: "Attend, Serve…" }, + { path: "icon", label: "Icon", help: "One emoji" }, + { path: "blurb", label: "Blurb", full: true }, + ], + children: [ + { + key: "actions", + label: "Actions", + addLabel: "Add action", + title: (row) => row.label || "New action", + blank: { label: "", url: "" }, + fields: [ + { path: "label", label: "Label", required: true }, + { path: "url", label: "Link", required: true }, + { path: "description", label: "Description", full: true }, + ], + }, + ], + }, + ], +}; + +export const ADMIN_ENTITIES = { + organizations, + events, + people, + teams, + awards, + timeline, + front_page: frontPage, +}; export function slugify(value) { return String(value ?? "") diff --git a/src/lib/embeds.ts b/src/lib/embeds.ts new file mode 100644 index 0000000..4f0f52e --- /dev/null +++ b/src/lib/embeds.ts @@ -0,0 +1,62 @@ +/* ═══════════════════════════════════════════════════════════════ + EMBEDS + + Turns the link an admin pastes into the URL an