v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

352
src/pages/EventDetail.tsx Normal file
View file

@ -0,0 +1,352 @@
/* ═══════════════════════════════════════════════════════════════
EVENT DETAIL — /retreats/:id
Reached from the cards on Retreats.tsx. The API has already
resolved the fallbacks — `color` is the event's own or its first
host's, `status` is derived from the dates when nobody set it —
so nothing here reimplements those rules.
Hosts arrive as a list in billing order, each one either an
organization or a person. refHref takes the kind and works out
the route, which is why this file doesn't branch on it.
People are grouped by their billing role rather than listed flat.
A retreat with four speakers and eleven volunteers reads as two
different things, and v_event_people already sorts within a role
by sort_order.
═══════════════════════════════════════════════════════════════ */
import { Link, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
import {
useEvent,
type EventHost,
type EventPerson,
type EventRecord,
} from '../lib/useContent.ts'
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
import { personPhoto } from '../lib/media.ts'
import { eventTypeLabel } from '../lib/eventTypes.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
/* Billing order. A role missing from here still renders, at the
end, under its own name — better than a speaker vanishing
because somebody added a role to the CHECK and not to this list. */
const ROLE_ORDER = [
'speaker',
'leader',
'facilitator',
'host',
'musician',
'volunteer',
'attendee',
] as const
const ROLE_LABEL: Record<string, string> = {
speaker: 'Speakers',
leader: 'Leaders',
facilitator: 'Facilitators',
host: 'Hosts',
musician: 'Music',
volunteer: 'Volunteers',
attendee: 'Also there',
}
const STATUS_LABEL: Record<string, string> = {
upcoming: 'Upcoming',
past: 'Past',
cancelled: 'Cancelled',
}
export default function EventDetail() {
const { id } = useParams()
const { data: event, loading, error, notFound, reload } = useEvent(id)
if (!event) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="retreat"
backTo="/retreats"
backLabel="All retreats"
/>
)
}
const accent = event.color || TEAL
const groups = peopleGroups(event.people, accent)
const sections = [
{
id: 'about',
title: event.theme || 'About',
blurb: event.theme ? event.tagline ?? undefined : undefined,
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts event={event} accent={accent} />
{event.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={event.blocks} accent={accent} />
</div>
{event.links.length > 0 && (
<div className="flex flex-wrap gap-3">
{event.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
</div>
)}
</div>
),
},
]
if (groups.length > 0) {
sections.push({
id: 'people',
title: 'Who’s there',
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="lg" groups={groups} accent={accent} />
</div>
),
})
}
if (event.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Presented here',
blurb: 'Awards given at this gathering.',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-6">
{event.awards.map((entry, index) => (
<div
key={`${entry.award.id}:${entry.person.id}:${index}`}
className="flex gap-4 border-l-2 pl-5"
style={{ borderColor: accent }}
>
<div>
<p className="font-semibold" style={{ color: accent }}>
<Link to={awardHref(entry.award.id)} className="hover:underline">
{entry.award.name}
</Link>
</p>
<p style={{ color: BODY }}>
<Link to={personHref(entry.person.id)} className="hover:underline">
{entry.person.name}
</Link>
</p>
{entry.citation && (
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
{entry.citation}
</p>
)}
</div>
</div>
))}
</div>
),
})
}
return (
<PageShell
title={event.title}
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
sections={sections}
/>
)
}
/* ── The strip of facts under the heading ────────────────────── */
function Facts({ event, accent }: { event: EventRecord; accent: string }) {
const where =
event.location_label ||
[event.locality, event.state_code].filter(Boolean).join(', ') ||
(event.is_online ? 'Online' : null)
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{
background: event.status === 'cancelled' ? '#b3261e' : accent,
color: '#ffffff',
}}
>
{STATUS_LABEL[event.status] ?? event.status}
</span>
{/* Outlined rather than filled: the status pill is the one
thing in this strip that should read as loud, and two
solid blocks side by side would compete. Shown
unconditionally — a card suppresses its own type badge in
a band of one kind, but here there is no band to make it
redundant. */}
{event.event_type && (
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{eventTypeLabel(event.event_type)}
</span>
)}
{when && <span style={{ color: BODY }}>{when}</span>}
{where && <span style={{ color: BODY }}>{where}</span>}
{event.is_online && where !== 'Online' && (
<span style={{ color: BODY }}>Online too</span>
)}
{(event.hosts?.length ?? 0) > 0 && (
<span style={{ color: BODY }}>
Hosted by <HostList hosts={event.hosts} accent={accent} />
</span>
)}
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
All retreats
</Link>
</div>
)
}
/* One host reads as "Hosted by Northwest"; several read as a
sentence, so they're joined with commas and an "and" rather than
stacked. A host whose id didn't resolve to a route renders as
plain text — refHref returns null for that — because a dead link
is worse than a name. */
function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) {
return (
<>
{hosts.map((host, index) => {
const to = refHref(host.kind, host.id, host.org_kind)
return (
<span key={`${host.kind}:${host.id}`}>
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
{index > 0 && index === hosts.length - 1 && 'and '}
{to ? (
<Link
to={to}
className="font-medium hover:underline"
style={{ color: accent }}
>
{host.name}
</Link>
) : (
<span className="font-medium">{host.name}</span>
)}
</span>
)
})}
</>
)
}
/* date_label is what a card shows and is free text — "March/April
2026" is legitimate. This is only the fallback for an event that
has dates and no label. */
function dateRange(start?: string | null, end?: string | null): string | null {
if (!start) return null
const from = new Date(`${start}T00:00:00`)
if (Number.isNaN(from.getTime())) return start
const full: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
}
if (!end || end === start) return from.toLocaleDateString(undefined, full)
const to = new Date(`${end}T00:00:00`)
if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full)
const sameYear = from.getFullYear() === to.getFullYear()
const sameMonth = sameYear && from.getMonth() === to.getMonth()
const left = from.toLocaleDateString(
undefined,
sameMonth
? { month: 'long', day: 'numeric' }
: sameYear
? { month: 'long', day: 'numeric' }
: full,
)
return `${left} – ${to.toLocaleDateString(undefined, full)}`
}
/* ── v_event_people → PeopleTiles groups ─────────────────────── */
function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] {
if (!people.length) return []
const byRole = new Map<string, EventPerson[]>()
for (const person of people) {
const role = person.role || 'attendee'
const list = byRole.get(role)
if (list) list.push(person)
else byRole.set(role, [person])
}
// Known roles in billing order, then anything the CHECK has
// gained since this file was written.
const roles = [
...ROLE_ORDER.filter((role) => byRole.has(role)),
...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)),
]
return roles.map((role) => ({
id: `role-${role}`,
label: ROLE_LABEL[role] ?? capitalize(role),
accent,
people: (byRole.get(role) ?? []).map((person) => ({
id: person.person_id,
name: person.display_name,
title: person.title,
tagline: person.tagline,
pronouns: person.pronouns,
// PeopleTiles resolves a bare filename itself; this is here
// so a hand-written entry elsewhere can't diverge.
photo: personPhoto(person.photo),
})),
}))
}
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)