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