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

@ -0,0 +1,133 @@
/* ═══════════════════════════════════════════════════════════════
NEXT EVENT COUNTDOWN
A strip under the hero: the next event, and how long until it.
The API picks the event (pinned in the admin, or the next one that
hasn't ended); this works out the moment to count to.
For a one-off event that's local midnight on starts_on — dates
here are calendar dates with no time attached. For a series it's
the next meeting, at the series' start time when it has one, so a
weekly class counts down to Tuesday 7pm rather than to a start
date months in the past.
Once the moment passes and the event hasn't ended, the strip says
it's happening now instead of counting below zero.
The strip's heading is the event itself, so the section title and
blurb from the admin aren't drawn here.
═══════════════════════════════════════════════════════════════ */
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { eventHref } from '../../../lib/hrefs.ts'
import { upcomingOccurrences } from '../../../lib/eventSeries.ts'
import type { CountdownEvent } from '../../../lib/useFrontPage.ts'
const TEAL = '#138ba0'
/* Local midnight (or HH:MM) on a 'YYYY-MM-DD'. */
function localMoment(date: string, time?: string | null): Date | null {
const [y, m, d] = date.split('-').map(Number)
if (!y || !m || !d) return null
const [hh, mm] = (time ?? '00:00').split(':').map(Number)
return new Date(y, m - 1, d, hh || 0, mm || 0)
}
function target(event: CountdownEvent): Date | null {
if (event.series) {
const next = upcomingOccurrences(event.series, event.starts_on, event.ends_on, 1)[0]
return next ? localMoment(next, event.series.start_time) : null
}
return event.starts_on ? localMoment(event.starts_on) : null
}
function useNow(intervalMs: number) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), intervalMs)
return () => window.clearInterval(timer)
}, [intervalMs])
return now
}
/* `overlap` tucks the strip up over the hero's bottom edge, which
only makes sense when it's the first band after the hero. The
admin can move it anywhere, so Home decides. */
export default function NextEventCountdown({
event,
overlap,
}: {
event: CountdownEvent
overlap: boolean
}) {
const now = useNow(1000)
const when = target(event)
const accent = event.color || TEAL
const remaining = when ? when.getTime() - now : 0
const live = !when || remaining <= 0
const parts = [
['days', Math.floor(remaining / 86_400_000)],
['hrs', Math.floor(remaining / 3_600_000) % 24],
['min', Math.floor(remaining / 60_000) % 60],
['sec', Math.floor(remaining / 1000) % 60],
] as const
const where = event.location_label || (event.is_online ? 'Online' : null)
return (
<section
aria-label="Next event"
className={`relative z-20 px-6 ${overlap ? '-mt-12' : 'py-12'}`}
>
<Link
to={eventHref(event.id)}
className="group mx-auto flex max-w-6xl flex-col gap-6 rounded-3xl border bg-white/95 p-6 shadow-2xl backdrop-blur transition-transform duration-300 hover:-translate-y-1 md:flex-row md:items-center md:p-8"
style={{ borderColor: `${accent}55` }}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-[0.25em]" style={{ color: accent }}>
{live ? 'Happening now' : 'Next up'}
</p>
<p className="mt-1 truncate font-display text-2xl font-extrabold text-[#073d4a] md:text-3xl">
{event.title}
</p>
<p className="mt-1 text-sm text-[#4a6b72]">
{[event.theme && `“${event.theme}”`, event.date_label, where]
.filter(Boolean)
.join(' · ')}
</p>
</div>
{!live && (
<div className="flex gap-2 md:gap-3" role="timer" aria-live="off">
{parts.map(([label, value]) => (
<div
key={label}
className="flex w-16 flex-col items-center rounded-2xl py-3 text-white md:w-20"
style={{ background: `linear-gradient(160deg, ${accent}, #073d4a)` }}
>
<span className="font-display text-2xl font-bold tabular-nums md:text-3xl">
{String(value).padStart(2, '0')}
</span>
<span className="text-[0.65rem] uppercase tracking-widest text-white/75">
{label}
</span>
</div>
))}
</div>
)}
<span
className="self-start text-sm font-semibold transition-transform group-hover:translate-x-1 md:self-center"
style={{ color: accent }}
>
Details →
</span>
</Link>
</section>
)
}