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:
parent
b4b013209b
commit
6a69084de2
25 changed files with 2299 additions and 494 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue