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>
186 lines
6.5 KiB
JavaScript
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;
|