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>
This commit is contained in:
Zaldimmar 2026-09-25 05:37:42 -05:00
parent b4b013209b
commit 6a69084de2
25 changed files with 2299 additions and 494 deletions

View file

@ -86,6 +86,12 @@ export default function EntityEdit() {
// references an event has no name of its own.
const autoId = manifest?.idKind === "auto";
// A singleton has one row, made by its migration: no slug to show
// or type, no list to go back to, nothing to delete. The server
// refuses create and delete regardless; this only stops offering
// them.
const singleton = Boolean(manifest?.singleton);
// Hoisted above the loading guards: the title hook below is a
// hook, so it can't sit after an early return, and it needs the
// same paths the heading uses.
@ -215,7 +221,7 @@ export default function EntityEdit() {
/* ── Heading ───────────────────────────────────────────────── */
const heading = headingOf(form);
const heading = singleton ? manifest.label : headingOf(form);
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
const children = manifest.children ?? [];
@ -311,6 +317,7 @@ export default function EntityEdit() {
return (
<div className="pb-24">
{!singleton && (
<button
type="button"
onClick={() => leave(`/admin/${manifest.key}`)}
@ -318,6 +325,7 @@ export default function EntityEdit() {
>
← {manifest.label}
</button>
)}
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
{isNew ? `New ${manifest.singular}` : heading}
@ -326,7 +334,13 @@ export default function EntityEdit() {
{/* Slug. An auto-id entity has nothing to ask for on create, and
nothing editable afterwards — so it gets a plain line rather
than a disabled box pretending to be a field. */}
{autoId ? (
{singleton ? (
updatedAt && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">Last saved {updatedAt}</p>
</div>
)
) : autoId ? (
!isNew && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
@ -425,7 +439,7 @@ export default function EntityEdit() {
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && canDelete && (
{!isNew && !singleton && canDelete && (
<button
type="button"
onClick={remove}

View file

@ -13,7 +13,7 @@
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
@ -46,7 +46,7 @@ export default function EntityList() {
const canWrite = atLeast(user, "editor");
const load = useCallback(async () => {
if (!manifest) return;
if (!manifest || manifest.singleton) return;
setLoading(true);
setError(null);
try {
@ -72,6 +72,11 @@ export default function EntityList() {
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
}
// One row, so no list: the tab opens the row.
if (manifest.singleton) {
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
}
function setParam(key: string, value: string) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);

View file

@ -64,6 +64,12 @@ export const CMS_NAV = [
label: "Timeline",
blurb: "What the history page shows, and the order it shows it in.",
},
// One record, not a list — the tab opens it directly.
{
to: "/admin/front_page",
label: "Front page",
blurb: "The hero, its photos or livestream, and which bands the home page draws.",
},
];
/* Forms are submissions coming in rather than content going out, so