v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
352
src/pages/EventDetail.tsx
Normal file
352
src/pages/EventDetail.tsx
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue