NGU-Web/server/src/routes/home.js
Zaldimmar 6a69084de2 Replace the front page with an admin-driven home
The home page is rebuilt from scratch and configured from a new
Front page tab in the admin, backed by migration 017 and served by
GET /api/front-page.

Hero: brand, photos (crossfading slideshow with progress and pause)
or livestream (YouTube/Facebook/Vimeo embed with a LIVE badge),
switched by hand. After it, bands the admin can reorder, retitle or
hide: a countdown to the next event (series-aware), the National
Retreats carousel, a numbers band (typed in or counted from the
database), a horizontal rail of featured timeline entries, and a
"Find your way in" pathfinder replacing the old connect section.

The CRUD engine gains a `singleton` flag: the entity has one row,
made by its migration, and create and delete are refused. The list
screen opens that row and the editor drops the slug, back link and
delete. shapeSeries moves to shape.js so /front-page can share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:37:42 -05:00

186 lines
6.5 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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;