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
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FEATURED TIMELINE RAIL
|
||||
|
||||
The history page's featured entries, sideways: oldest on the
|
||||
left, so scrolling right moves forward through time. A line runs
|
||||
under the cards with a dot per entry, and a year label wherever
|
||||
the year changes. The last card goes to the full history.
|
||||
|
||||
Featured is the admin's call — timeline_entries.is_featured,
|
||||
"shown large" on the history page. Nothing else is filtered
|
||||
here; /history has already decided what's public.
|
||||
|
||||
Scrolling is native — touch, trackpad, shift-wheel — with
|
||||
scroll-snap so it settles on a card. A mouse can also drag it, and
|
||||
the arrow buttons step one card. A drag that moved more than a few
|
||||
pixels swallows the click it ends in, so letting go over a card
|
||||
doesn't open it.
|
||||
|
||||
Nothing featured, and the section doesn't render at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import HomeLink from './HomeLink.tsx'
|
||||
import { useHistory } from '../../../lib/useHistory.ts'
|
||||
import { hrefFor, logoSrc } from '../../../lib/timelineRefs.ts'
|
||||
import { MONTH_LABELS, type TimelineItem } from '../../../lib/timeline.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const DRAG_SLOP = 6
|
||||
|
||||
type RailProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function FeaturedTimelineRail({ id, title, blurb }: RailProps) {
|
||||
const { items, loading, error, reload } = useHistory()
|
||||
const featured = items
|
||||
.filter((item) => item.featured)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const railRef = useRef<HTMLOListElement>(null)
|
||||
const drag = useRef({ x: 0, left: 0, moved: false, active: false })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
if (!loading && !error && featured.length === 0) return null
|
||||
|
||||
const step = (direction: number) => {
|
||||
const rail = railRef.current
|
||||
const card = rail?.querySelector('li')
|
||||
if (!rail || !card) return
|
||||
rail.scrollBy({ left: direction * (card.clientWidth + 24), behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const onPointerDown = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||
if (e.pointerType !== 'mouse' || !railRef.current) return
|
||||
drag.current = { x: e.clientX, left: railRef.current.scrollLeft, moved: false, active: true }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||
const d = drag.current
|
||||
if (!d.active || !railRef.current) return
|
||||
const dx = e.clientX - d.x
|
||||
if (!d.moved && Math.abs(dx) > DRAG_SLOP) {
|
||||
d.moved = true
|
||||
setDragging(true)
|
||||
railRef.current.setPointerCapture(e.pointerId)
|
||||
}
|
||||
if (d.moved) railRef.current.scrollLeft = d.left - dx
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
drag.current.active = false
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="overflow-hidden py-24" style={{ background: '#f4faf7' }}>
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||
<div>
|
||||
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
|
||||
{featured.length > 1 && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{[
|
||||
[-1, '‹', 'Earlier'],
|
||||
[1, '›', 'Later'],
|
||||
].map(([direction, glyph, label]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => step(direction as number)}
|
||||
aria-label={label as string}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full border text-2xl transition-colors hover:bg-white"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mx-auto mt-10 max-w-6xl px-6 text-[#b3261e]">
|
||||
Couldn’t load the timeline. {error}{' '}
|
||||
<button type="button" onClick={reload} className="underline">
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="mx-auto mt-12 flex max-w-6xl gap-6 px-6" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-72 w-80 shrink-0 animate-pulse rounded-3xl bg-white" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ol
|
||||
ref={railRef}
|
||||
className={`hp-rail mt-12 flex gap-6 overflow-x-auto px-6 pb-4 md:px-[max(1.5rem,calc((100vw-72rem)/2+1.5rem))] ${
|
||||
dragging ? 'hp-rail--dragging' : ''
|
||||
}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
// Links and images are natively draggable, and a native
|
||||
// drag cancels the pointer stream this relies on.
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onClickCapture={(e) => {
|
||||
if (drag.current.moved) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
drag.current.moved = false
|
||||
}
|
||||
}}
|
||||
>
|
||||
{featured.map((item, index) => (
|
||||
<li key={item.id} className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||
<RailCard
|
||||
item={item}
|
||||
showYear={index === 0 || year(item) !== year(featured[index - 1])}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-12" />
|
||||
<Link
|
||||
to="/history"
|
||||
draggable={false}
|
||||
className="flex flex-1 flex-col justify-center rounded-3xl p-8 text-white transition-transform duration-300 hover:-translate-y-1"
|
||||
style={{ background: `linear-gradient(150deg, ${TEAL}, #073d4a)` }}
|
||||
>
|
||||
<span className="font-display text-2xl font-extrabold">The whole story</span>
|
||||
<span className="mt-2 text-white/75">Every year, every milestone.</span>
|
||||
<span className="mt-6 font-semibold">See the full history →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const year = (item: TimelineItem) => item.date.slice(0, 4)
|
||||
|
||||
/* "June 2014", "2014", "12 June 2014" — only as much as precision
|
||||
says is true. */
|
||||
function dateLabel(item: TimelineItem): string {
|
||||
const [y, m, d] = item.date.split('-')
|
||||
const month = m ? MONTH_LABELS[Number(m) - 1] : null
|
||||
if (item.precision === 'day' && d && month) return `${Number(d)} ${month} ${y}`
|
||||
if (item.precision !== 'year' && month) return `${month} ${y}`
|
||||
return y
|
||||
}
|
||||
|
||||
function RailCard({ item, showYear }: { item: TimelineItem; showYear: boolean }) {
|
||||
const href = hrefFor(item)
|
||||
const logo = logoSrc(item)
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
className="mb-5 h-14 w-14 object-contain"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em]" style={{ color: TEAL }}>
|
||||
{dateLabel(item)}
|
||||
</p>
|
||||
<p className="mt-2 font-display text-xl font-bold leading-snug text-[#073d4a]">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.meta && <p className="mt-1 text-sm text-[#4a6b72]">{item.meta}</p>}
|
||||
{item.blurb && (
|
||||
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||
)}
|
||||
{href && (
|
||||
<span className="mt-auto pt-5 text-sm font-semibold" style={{ color: TEAL }}>
|
||||
Read more →
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const card =
|
||||
'flex flex-1 flex-col rounded-3xl border border-[#138ba0]/15 bg-white p-7 shadow-sm'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* The line, its dot, and the year where it changes. */}
|
||||
<div className="relative mb-4 h-8">
|
||||
<div className="absolute inset-x-[-1.5rem] top-1/2 h-px bg-[#138ba0]/30" />
|
||||
<span
|
||||
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 bg-white"
|
||||
style={{ borderColor: TEAL }}
|
||||
/>
|
||||
{showYear && (
|
||||
<span
|
||||
className="absolute left-5 top-1/2 -translate-y-1/2 rounded-full px-3 py-0.5 font-display text-sm font-bold text-white"
|
||||
style={{ background: TEAL }}
|
||||
>
|
||||
{year(item)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{href ? (
|
||||
<HomeLink
|
||||
url={href}
|
||||
className={`${card} transition-all duration-300 hover:-translate-y-1 hover:shadow-xl`}
|
||||
>
|
||||
{body}
|
||||
</HomeLink>
|
||||
) : (
|
||||
<div className={card}>{body}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
src/pages/sections/home/HeroStage.tsx
Normal file
307
src/pages/sections/home/HeroStage.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
HERO STAGE
|
||||
|
||||
The top of the front page, in whichever mode the admin set:
|
||||
|
||||
brand drifting colour and slow concentric rings — many
|
||||
circles, one centre — behind the words
|
||||
photos the hero photos, crossfading with a slow zoom, a
|
||||
progress bar per photo and a pause button (anything
|
||||
that moves on its own for more than five seconds
|
||||
needs one)
|
||||
livestream the stream beside the words, with a LIVE badge
|
||||
|
||||
A mode that has nothing to show falls back to brand: photos with
|
||||
no photos, or a livestream link that can't be embedded. A stream
|
||||
link that can't be framed still gets a "Watch live" button, so
|
||||
switching the mode on is never a no-op.
|
||||
|
||||
`hero` is null while the page config loads or when it failed; the
|
||||
stage still draws, empty, so the page doesn't jump when it
|
||||
arrives. The error itself is shown by Home, not here.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
|
||||
import HomeLink from './HomeLink.tsx'
|
||||
import { livestreamEmbedUrl } from '../../../lib/embeds.ts'
|
||||
import { heroPhoto } from '../../../lib/media.ts'
|
||||
import type { Hero, HeroSlide } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
const DEEP = '#04262e'
|
||||
|
||||
export default function HeroStage({ hero }: { hero: Hero | null }) {
|
||||
const slides = (hero?.slides ?? []).filter((slide) => heroPhoto(slide.media))
|
||||
const embed = livestreamEmbedUrl(hero?.livestream?.url)
|
||||
|
||||
const mode =
|
||||
hero?.mode === 'photos' && slides.length > 0
|
||||
? 'photos'
|
||||
: hero?.mode === 'livestream' && hero.livestream
|
||||
? 'livestream'
|
||||
: 'brand'
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative isolate flex min-h-[92vh] items-center overflow-hidden pt-24 pb-16"
|
||||
style={{ background: DEEP }}
|
||||
>
|
||||
{mode === 'photos' ? (
|
||||
<PhotoBackdrop slides={slides} seconds={hero?.slide_seconds ?? 7} />
|
||||
) : (
|
||||
<BrandBackdrop />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mx-auto grid w-full max-w-7xl items-center gap-12 px-6 lg:grid-cols-12">
|
||||
<div className={mode === 'livestream' ? 'lg:col-span-5' : 'lg:col-span-8'}>
|
||||
{hero && <Words hero={hero} live={mode === 'livestream'} />}
|
||||
</div>
|
||||
|
||||
{mode === 'livestream' && hero?.livestream && (
|
||||
<div className="lg:col-span-7">
|
||||
<LiveFrame src={embed} url={hero.livestream.url} title={hero.livestream.title} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The words ───────────────────────────────────────────────── */
|
||||
|
||||
function Words({ hero, live }: { hero: Hero; live: boolean }) {
|
||||
const words = hero.headline.split(/\s+/).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="text-white">
|
||||
{live ? (
|
||||
<p className="mb-6 inline-flex items-center gap-3 rounded-full bg-white/10 px-4 py-1.5 text-sm font-semibold uppercase tracking-[0.2em] backdrop-blur">
|
||||
<span className="hp-live-dot h-2.5 w-2.5 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||
Live now
|
||||
{hero.livestream?.title && (
|
||||
<span className="normal-case tracking-normal text-white/75">
|
||||
· {hero.livestream.title}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
hero.eyebrow && (
|
||||
<p className="mb-6 inline-block rounded-full border border-white/20 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.25em] text-[#9fe7d0]">
|
||||
{hero.eyebrow}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
<h1
|
||||
className={`font-display font-extrabold leading-[0.95] tracking-tight ${
|
||||
live ? 'text-5xl md:text-6xl' : 'text-6xl md:text-8xl'
|
||||
}`}
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<span key={`${word}-${index}`}>
|
||||
<span
|
||||
className="hp-rise"
|
||||
style={{ animationDelay: `${120 + index * 110}ms` }}
|
||||
>
|
||||
{word}
|
||||
</span>{' '}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
{hero.subhead && (
|
||||
<p
|
||||
className="hp-rise mt-8 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl"
|
||||
style={{ animationDelay: `${200 + words.length * 110}ms` }}
|
||||
>
|
||||
{hero.subhead}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(hero.primary || hero.secondary) && (
|
||||
<div
|
||||
className="hp-rise mt-10 flex flex-wrap gap-4"
|
||||
style={{ animationDelay: `${320 + words.length * 110}ms` }}
|
||||
>
|
||||
{hero.primary && (
|
||||
<HomeLink
|
||||
url={hero.primary.url}
|
||||
className="rounded-full px-8 py-4 text-lg font-bold text-[#04262e] shadow-xl transition-transform duration-300 hover:-translate-y-0.5 hover:scale-[1.03]"
|
||||
style={{ background: 'linear-gradient(120deg, #9fe7d0, #5ce7ff)' }}
|
||||
>
|
||||
{hero.primary.label}
|
||||
</HomeLink>
|
||||
)}
|
||||
{hero.secondary && (
|
||||
<HomeLink
|
||||
url={hero.secondary.url}
|
||||
className="rounded-full border border-white/35 px-8 py-4 text-lg font-semibold text-white transition-colors duration-300 hover:bg-white/10"
|
||||
>
|
||||
{hero.secondary.label} →
|
||||
</HomeLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Brand backdrop ──────────────────────────────────────────── */
|
||||
|
||||
function BrandBackdrop() {
|
||||
return (
|
||||
<div className="absolute inset-0 -z-10" aria-hidden="true">
|
||||
<div className="hp-aurora hp-aurora--a" style={blob('#138ba0', '48vw', '-10%', '-10%')} />
|
||||
<div className="hp-aurora hp-aurora--b" style={blob('#10d48a', '38vw', '45%', '20%')} />
|
||||
<div className="hp-aurora hp-aurora--c" style={blob('#d8b64a', '30vw', '70%', '-5%')} />
|
||||
|
||||
<svg
|
||||
className="absolute -right-[20vw] top-1/2 h-[120vw] w-[120vw] -translate-y-1/2 opacity-[0.16] md:-right-[10vw] md:h-[80vw] md:w-[80vw]"
|
||||
viewBox="0 0 400 400"
|
||||
>
|
||||
<g className="hp-rings" fill="none" stroke="#ffffff">
|
||||
{[40, 70, 100, 130, 160, 190].map((r, i) => (
|
||||
<circle key={r} cx="200" cy="200" r={r} strokeWidth={i % 2 ? 0.6 : 1.2} strokeDasharray={i % 2 ? '2 6' : undefined} />
|
||||
))}
|
||||
<circle cx="390" cy="200" r="4" fill="#9fe7d0" stroke="none" />
|
||||
<circle cx="200" cy="40" r="3" fill="#5ce7ff" stroke="none" />
|
||||
</g>
|
||||
<g className="hp-rings hp-rings--reverse" fill="none" stroke="#9fe7d0">
|
||||
<circle cx="200" cy="200" r="115" strokeWidth="0.8" strokeDasharray="1 10" />
|
||||
<circle cx="85" cy="200" r="3.5" fill="#ffffff" stroke="none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-40"
|
||||
style={{ background: `linear-gradient(to bottom, transparent, ${DEEP})` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function blob(color: string, size: string, left: string, top: string): CSSProperties {
|
||||
return { background: color, width: size, height: size, left, top }
|
||||
}
|
||||
|
||||
/* ── Photo backdrop ──────────────────────────────────────────── */
|
||||
|
||||
function PhotoBackdrop({ slides, seconds }: { slides: HeroSlide[]; seconds: number }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const ms = Math.max(3, seconds) * 1000
|
||||
const current = slides[index % slides.length]
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || slides.length < 2) return
|
||||
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % slides.length), ms)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [index, paused, ms, slides.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 -z-10"
|
||||
style={{ '--hp-slide-ms': `${ms}ms` } as CSSProperties}
|
||||
>
|
||||
{slides.map((slide, i) => (
|
||||
<div
|
||||
key={`${slide.media}-${i}`}
|
||||
className={`hp-slide ${i === index ? 'hp-slide--on' : ''}`}
|
||||
aria-hidden={i !== index}
|
||||
>
|
||||
<img src={heroPhoto(slide.media) ?? ''} alt={slide.alt ?? ''} loading={i === 0 ? 'eager' : 'lazy'} />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(100deg, rgba(4,38,46,0.92) 0%, rgba(4,38,46,0.65) 45%, rgba(4,38,46,0.15) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-6 z-10 mx-auto flex max-w-7xl flex-wrap items-end gap-4 px-6">
|
||||
{current?.caption && (
|
||||
<p className="max-w-md rounded-xl bg-black/35 px-4 py-2 text-sm text-white/90 backdrop-blur">
|
||||
{current.link_url ? (
|
||||
<HomeLink url={current.link_url} className="hover:underline">
|
||||
{current.caption} →
|
||||
</HomeLink>
|
||||
) : (
|
||||
current.caption
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{slides.length > 1 && (
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={`${slide.media}-${i}`}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||
aria-current={i === index}
|
||||
className="h-1.5 w-10 overflow-hidden rounded-full bg-white/25"
|
||||
>
|
||||
<span
|
||||
// Re-keyed per index so the fill restarts on every change.
|
||||
key={`${index}-${i}`}
|
||||
className={`hp-progress block h-full bg-white ${
|
||||
i < index ? 'hp-progress--done' : i === index ? 'hp-progress--run' : ''
|
||||
} ${paused ? 'hp-progress--paused' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
aria-label={paused ? 'Play slideshow' : 'Pause slideshow'}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/40 text-xs text-white hover:bg-white/10"
|
||||
>
|
||||
{paused ? '▶' : '❚❚'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Livestream ──────────────────────────────────────────────── */
|
||||
|
||||
function LiveFrame({ src, url, title }: { src: string | null; url: string; title?: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-white/15 bg-black shadow-2xl"
|
||||
style={{ boxShadow: '0 30px 80px -20px rgba(16, 212, 138, 0.35)' }}
|
||||
>
|
||||
<div className="aspect-video w-full">
|
||||
{src ? (
|
||||
<iframe
|
||||
src={src}
|
||||
title={title || 'Livestream'}
|
||||
className="h-full w-full"
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-white">
|
||||
<span className="hp-live-dot h-4 w-4 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||
<HomeLink
|
||||
url={url}
|
||||
className="rounded-full bg-white px-6 py-3 font-semibold text-[#04262e] hover:scale-105"
|
||||
>
|
||||
Watch live
|
||||
</HomeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/pages/sections/home/HomeLink.tsx
Normal file
45
src/pages/sections/home/HomeLink.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
HOME LINK
|
||||
|
||||
Every link on the front page is typed into the admin, so any of
|
||||
them can be a route (/retreats), an anchor on this page
|
||||
(#connect) or somewhere else entirely. One component decides
|
||||
which element that is, so the hero buttons, photo captions and
|
||||
pathfinder actions can't disagree about it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type HomeLinkProps = {
|
||||
url: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const isExternal = (url: string) => /^[a-z][a-z0-9+.-]*:/i.test(url)
|
||||
|
||||
export default function HomeLink({ url, className, style, children }: HomeLinkProps) {
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
return (
|
||||
<Link to={url} className={className} style={style}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// mailto: and tel: open an app, not a tab.
|
||||
const newTab = isExternal(url) && !/^(mailto|tel):/i.test(url)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className={className}
|
||||
style={style}
|
||||
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
133
src/pages/sections/home/NextEventCountdown.tsx
Normal file
133
src/pages/sections/home/NextEventCountdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
src/pages/sections/home/Pathfinder.tsx
Normal file
186
src/pages/sections/home/Pathfinder.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PATHFINDER — "Find your way in"
|
||||
|
||||
The connect section as a question rather than a wall of forms:
|
||||
pick what you're here for, and that path's actions arrive. Paths
|
||||
and actions are the admin's (Front page → Find your way in);
|
||||
a path with no actions never reaches this component.
|
||||
|
||||
The choices are a real tablist: arrow keys move between them,
|
||||
Home and End jump to the ends, and only the selected tab is in
|
||||
the tab order. Selection follows focus, which is right when
|
||||
showing a panel costs nothing.
|
||||
|
||||
Re-keying the panel on the selected index is what replays the
|
||||
entrance animation for each choice.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useId, useRef, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import HomeLink, { isExternal } from './HomeLink.tsx'
|
||||
import type { FrontPagePath } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
type PathfinderProps = {
|
||||
id: string
|
||||
title: string
|
||||
blurb?: string | null
|
||||
paths: FrontPagePath[]
|
||||
}
|
||||
|
||||
/* One accent per position, cycling. Paths are data; colour is
|
||||
presentation, so it's assigned here rather than stored. */
|
||||
const ACCENTS = ['#138ba0', '#10a36e', '#c7972b', '#7a5ea8', '#d0643c']
|
||||
|
||||
export default function Pathfinder({ id, title, blurb, paths }: PathfinderProps) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
const tabs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const base = useId().replace(/:/g, '')
|
||||
|
||||
if (paths.length === 0) return null
|
||||
|
||||
const path = paths[Math.min(selected, paths.length - 1)]
|
||||
const accent = ACCENTS[selected % ACCENTS.length]
|
||||
|
||||
const focus = (index: number) => {
|
||||
const next = (index + paths.length) % paths.length
|
||||
setSelected(next)
|
||||
tabs.current[next]?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: selected + 1,
|
||||
ArrowDown: selected + 1,
|
||||
ArrowLeft: selected - 1,
|
||||
ArrowUp: selected - 1,
|
||||
Home: 0,
|
||||
End: paths.length - 1,
|
||||
}
|
||||
if (!(e.key in moves)) return
|
||||
e.preventDefault()
|
||||
focus(moves[e.key])
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="py-24" style={{ background: '#ffffff' }}>
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.3em] text-[#138ba0]">
|
||||
I’m looking to…
|
||||
</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-4 text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={title}
|
||||
aria-orientation="vertical"
|
||||
className="mt-10 flex flex-col gap-3"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{paths.map((option, index) => {
|
||||
const on = index === selected
|
||||
const color = ACCENTS[index % ACCENTS.length]
|
||||
return (
|
||||
<button
|
||||
key={`${option.label}-${index}`}
|
||||
ref={(el) => {
|
||||
tabs.current[index] = el
|
||||
}}
|
||||
id={`${base}-tab-${index}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={on}
|
||||
aria-controls={`${base}-panel`}
|
||||
tabIndex={on ? 0 : -1}
|
||||
onClick={() => setSelected(index)}
|
||||
className="group flex items-center gap-4 rounded-2xl border-2 px-5 py-4 text-left transition-all duration-300"
|
||||
style={{
|
||||
borderColor: on ? color : 'rgba(19,139,160,0.12)',
|
||||
background: on ? `${color}12` : '#ffffff',
|
||||
transform: on ? 'translateX(8px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ background: on ? color : `${color}1f` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{option.icon || '•'}
|
||||
</span>
|
||||
<span className="font-display text-xl font-bold" style={{ color: on ? color : '#073d4a' }}>
|
||||
{option.label}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-xl transition-opacity"
|
||||
style={{ color, opacity: on ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key={selected}
|
||||
id={`${base}-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${base}-tab-${selected}`}
|
||||
className="relative overflow-hidden rounded-[2rem] p-8 md:p-10 lg:col-span-7"
|
||||
style={{ background: `linear-gradient(155deg, ${accent}14, ${accent}05 60%, #ffffff)` }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute -right-6 -top-10 select-none text-[10rem] leading-none opacity-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{path.icon}
|
||||
</span>
|
||||
|
||||
{path.blurb && (
|
||||
<p className="hp-pop relative max-w-md font-display text-2xl font-semibold leading-snug text-[#073d4a]">
|
||||
{path.blurb}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="relative mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{path.actions.map((action, index) => (
|
||||
<li
|
||||
key={`${action.url}-${index}`}
|
||||
className="hp-pop"
|
||||
style={{ animationDelay: `${120 + index * 90}ms` }}
|
||||
>
|
||||
<HomeLink
|
||||
url={action.url}
|
||||
className="group flex h-full flex-col rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
>
|
||||
<span className="flex items-start gap-2 font-display text-lg font-bold text-[#073d4a]">
|
||||
{action.label}
|
||||
<span
|
||||
className="ml-auto transition-transform group-hover:translate-x-1"
|
||||
style={{ color: accent }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isExternal(action.url) ? '↗' : '→'}
|
||||
</span>
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className="mt-2 text-sm leading-relaxed text-[#4a6b72]">
|
||||
{action.description}
|
||||
</span>
|
||||
)}
|
||||
</HomeLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
RETREATS BAND
|
||||
|
||||
The National Retreats carousel from the Retreats page, as-is:
|
||||
same component, same filter, so an event edited in the admin
|
||||
shows up identically in both places. This file only adds the
|
||||
front page's heading and a way through to the full page.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import EventListCards from '../EventList-Cards.tsx'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
|
||||
type RetreatsBandProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
|
||||
return (
|
||||
<section id={id} className="overflow-hidden py-24" style={{ background: '#eef9fb' }}>
|
||||
<div className="mx-auto mb-12 flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||
<div>
|
||||
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
<Link
|
||||
to="/retreats"
|
||||
className="ml-auto rounded-full border px-5 py-2 text-sm font-semibold transition-colors hover:bg-white"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
All retreats →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EventListCards section="national" type="retreat" view="carousel" accent={TEAL} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
127
src/pages/sections/home/StatsBand.tsx
Normal file
127
src/pages/sections/home/StatsBand.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
STATS BAND
|
||||
|
||||
The numbers from the admin's Front page editor, counted by the
|
||||
API or typed in. Each one counts up from zero the first time the
|
||||
band scrolls into view; a value that isn't a plain number
|
||||
("Since 2004", "Coast to coast") just appears.
|
||||
|
||||
Reduced motion skips the count and shows the number.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { FrontPageStat } from '../../../lib/useFrontPage.ts'
|
||||
|
||||
const COUNT_MS = 1400
|
||||
|
||||
type StatsBandProps = {
|
||||
id: string
|
||||
title: string
|
||||
blurb?: string | null
|
||||
stats: FrontPageStat[]
|
||||
}
|
||||
|
||||
export default function StatsBand({ id, title, blurb, stats }: StatsBandProps) {
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const [seen, setSeen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || seen) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setSeen(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.35 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [seen])
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="relative overflow-hidden py-24"
|
||||
style={{ background: 'linear-gradient(160deg, #073d4a 0%, #04262e 70%)' }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute -left-40 -top-40 h-[32rem] w-[32rem] rounded-full opacity-25 blur-3xl"
|
||||
style={{ background: '#10d48a' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6">
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-[0.3em] text-[#9fe7d0]">
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-3 max-w-2xl text-white/70">{blurb}</p>}
|
||||
|
||||
<dl className="mt-12 grid grid-cols-2 gap-x-8 gap-y-14 md:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={`${stat.label}-${index}`}
|
||||
className={`hp-stat border-l border-white/15 pl-6 ${seen ? 'hp-stat--in' : ''}`}
|
||||
style={{ transitionDelay: `${index * 120}ms` }}
|
||||
>
|
||||
<dd className="font-display text-5xl font-extrabold leading-none text-white md:text-6xl">
|
||||
<CountUp value={stat.value} run={seen} />
|
||||
{stat.suffix && <span className="text-[#9fe7d0]">{stat.suffix}</span>}
|
||||
</dd>
|
||||
<dt className="mt-3 text-sm font-semibold uppercase tracking-widest text-white/70">
|
||||
{stat.label}
|
||||
</dt>
|
||||
{stat.note && <p className="mt-1 text-sm text-white/50">{stat.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* "1,200" counts to 1,200 and keeps its comma; "12.5" keeps its
|
||||
decimal. Anything that isn't just a number renders as given. */
|
||||
function CountUp({ value, run }: { value: string; run: boolean }) {
|
||||
const numeric = /^\d[\d,]*(\.\d+)?$/.test(value)
|
||||
const target = numeric ? Number(value.replace(/,/g, '')) : 0
|
||||
const decimals = value.split('.')[1]?.length ?? 0
|
||||
const grouped = value.includes(',')
|
||||
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const [shown, setShown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!numeric || !run || reduced) return
|
||||
let frame = 0
|
||||
const start = performance.now()
|
||||
const tick = (t: number) => {
|
||||
const p = Math.min(1, (t - start) / COUNT_MS)
|
||||
// Ease out: fast at first, settling onto the number.
|
||||
setShown(target * (1 - Math.pow(1 - p, 3)))
|
||||
if (p < 1) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [numeric, run, reduced, target])
|
||||
|
||||
if (!numeric) return <>{value}</>
|
||||
|
||||
const n = reduced || !run ? (run ? target : 0) : shown
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: grouped || target >= 10_000,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
156
src/pages/sections/home/home.css
Normal file
156
src/pages/sections/home/home.css
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FRONT PAGE MOTION
|
||||
|
||||
Keyframes and the few rules Tailwind utilities can't express.
|
||||
Everything that moves on its own stops under
|
||||
prefers-reduced-motion; the page still reads the same without it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Hero: brand mode aurora ─────────────────────────────────── */
|
||||
|
||||
.hp-aurora {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.55;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hp-aurora--a { animation: hp-drift-a 22s ease-in-out infinite alternate; }
|
||||
.hp-aurora--b { animation: hp-drift-b 28s ease-in-out infinite alternate; }
|
||||
.hp-aurora--c { animation: hp-drift-c 34s ease-in-out infinite alternate; }
|
||||
|
||||
@keyframes hp-drift-a {
|
||||
from { transform: translate(-10%, -5%) scale(1); }
|
||||
to { transform: translate(15%, 10%) scale(1.25); }
|
||||
}
|
||||
@keyframes hp-drift-b {
|
||||
from { transform: translate(10%, 5%) scale(1.1); }
|
||||
to { transform: translate(-20%, -10%) scale(0.9); }
|
||||
}
|
||||
@keyframes hp-drift-c {
|
||||
from { transform: translate(0, 10%) scale(0.9); }
|
||||
to { transform: translate(-10%, -15%) scale(1.2); }
|
||||
}
|
||||
|
||||
/* Concentric rings: many circles, one centre. */
|
||||
.hp-rings {
|
||||
animation: hp-spin 90s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.hp-rings--reverse { animation-direction: reverse; animation-duration: 140s; }
|
||||
|
||||
@keyframes hp-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Hero: headline words rise in ────────────────────────────── */
|
||||
|
||||
.hp-rise {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transform: translateY(0.6em);
|
||||
animation: hp-rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hp-rise {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Hero: photos ────────────────────────────────────────────── */
|
||||
|
||||
.hp-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 1.4s ease;
|
||||
}
|
||||
.hp-slide--on { opacity: 1; }
|
||||
|
||||
.hp-slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hp-slide--on img { animation: hp-kenburns var(--hp-slide-ms, 7000ms) ease-out forwards; }
|
||||
|
||||
@keyframes hp-kenburns {
|
||||
from { transform: scale(1.12) translate(1.5%, 1%); }
|
||||
to { transform: scale(1) translate(0, 0); }
|
||||
}
|
||||
|
||||
.hp-progress {
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
.hp-progress--run { animation: hp-fill var(--hp-slide-ms, 7000ms) linear forwards; }
|
||||
.hp-progress--done { transform: scaleX(1); }
|
||||
.hp-progress--paused { animation-play-state: paused; }
|
||||
|
||||
@keyframes hp-fill {
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
/* ── Hero: LIVE ──────────────────────────────────────────────── */
|
||||
|
||||
.hp-live-dot {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.7);
|
||||
animation: hp-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hp-pulse {
|
||||
to { box-shadow: 0 0 0 12px rgba(255, 77, 77, 0); }
|
||||
}
|
||||
|
||||
/* ── Timeline rail ───────────────────────────────────────────── */
|
||||
|
||||
.hp-rail {
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.hp-rail::-webkit-scrollbar { display: none; }
|
||||
.hp-rail--dragging { cursor: grabbing; scroll-snap-type: none; user-select: none; }
|
||||
.hp-rail > * { scroll-snap-align: start; }
|
||||
|
||||
/* ── Pathfinder: actions arrive one after another ────────────── */
|
||||
|
||||
.hp-pop {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.98);
|
||||
animation: hp-pop 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes hp-pop {
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── Stats: numbers settle in ────────────────────────────────── */
|
||||
|
||||
.hp-stat {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.hp-stat--in { opacity: 1; transform: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hp-aurora,
|
||||
.hp-rings,
|
||||
.hp-slide--on img,
|
||||
.hp-live-dot {
|
||||
animation: none;
|
||||
}
|
||||
.hp-rise,
|
||||
.hp-pop {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.hp-stat {
|
||||
transition: none;
|
||||
}
|
||||
.hp-slide {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue