The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
191 lines
7.5 KiB
JavaScript
191 lines
7.5 KiB
JavaScript
// One event's public page (EVENTS.md § API surface, Phase 14a).
|
|
//
|
|
// The storyline, its arc, what is live, what is next, what happened recently,
|
|
// and a results table once an occurrence has published one.
|
|
//
|
|
// **`?run=` is read from the URL and passed straight through**, because that is
|
|
// what an announcement's link carries. The page lives at the definition's slug —
|
|
// one stable address, so a link posted in Discord survives a retitle — and the
|
|
// occurrence has to be in the query string or a mail about last Friday's
|
|
// invasion would open next Friday's.
|
|
//
|
|
// **The error is checked before the form.** Phase 13 found the inverse of this
|
|
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
|
|
// branch left a failed load spinning for ever with nothing on screen naming the
|
|
// problem. Order matters, and the order is error first.
|
|
|
|
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
|
import PageHeader from '../../components/PageHeader.jsx'
|
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
|
import { useAsync } from '../../lib/useAsync.js'
|
|
import { api } from '../../api/client.js'
|
|
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
|
|
|
|
export default function EventPage() {
|
|
const { slug } = useParams()
|
|
const [params] = useSearchParams()
|
|
const run = params.get('run')
|
|
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
|
|
|
|
if (error) {
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-mid page-body">
|
|
<ErrorState message="That event could not be found." />
|
|
<p style={{ marginTop: 16 }}>
|
|
<Link to="/site/events">Back to the calendar</Link>
|
|
</p>
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
if (loading || !data) {
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-mid page-body">
|
|
<Loading />
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
const event = data.event
|
|
const headline = event.current || event.next
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-mid page-body">
|
|
<PageHeader
|
|
eyebrow={event.series ? event.series.name : 'Event'}
|
|
title={event.title}
|
|
lead={event.summary || ''}
|
|
/>
|
|
|
|
{event.series && (
|
|
<p className="sans" style={{ marginTop: -12 }}>
|
|
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
|
|
</p>
|
|
)}
|
|
|
|
{/* The one fact a visitor came for, before the storyline rather than
|
|
after it: whether it is happening now, and if not, when it next is. */}
|
|
<div
|
|
className="panel"
|
|
style={{
|
|
padding: '18px 22px',
|
|
marginBottom: 24,
|
|
borderColor: event.live ? '#8fc79a' : undefined,
|
|
}}
|
|
>
|
|
{event.live ? (
|
|
<>
|
|
<div
|
|
className="sans"
|
|
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
|
|
>
|
|
Happening now
|
|
</div>
|
|
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
|
{/* The phase LABEL, and only while it is live. The plan behind
|
|
the event is never published. */}
|
|
{event.current.phase || 'Under way'}
|
|
</div>
|
|
</>
|
|
) : event.next ? (
|
|
<>
|
|
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
|
|
Next
|
|
</div>
|
|
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
|
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="dim">Nothing scheduled at the moment.</div>
|
|
)}
|
|
</div>
|
|
|
|
{event.body && (
|
|
<article
|
|
className="panel"
|
|
style={{ padding: 28, marginBottom: 24 }}
|
|
// Sanitized on write, the treatment a wiki page and a forum post get.
|
|
dangerouslySetInnerHTML={{ __html: event.body }}
|
|
/>
|
|
)}
|
|
|
|
{event.results && (
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
|
Results
|
|
</h2>
|
|
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
|
|
{eventDateTime(event.results.scheduledFor, event.timezone)}
|
|
</p>
|
|
{event.results.participants.length === 0 ? (
|
|
<EmptyState>Results were published with nobody recorded.</EmptyState>
|
|
) : (
|
|
<table className="table" style={{ width: '100%' }}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 60 }}>#</th>
|
|
<th>Who</th>
|
|
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{event.results.participants.map((p, i) => (
|
|
<tr key={`${p.name || 'anon'}-${i}`}>
|
|
<td>{p.rank ?? '—'}</td>
|
|
{/* A module supplies a display name in `meta` or it does
|
|
not; the member key is never published, so there is
|
|
genuinely nothing else to render. */}
|
|
<td>{p.name || <span className="dim">Unnamed</span>}</td>
|
|
<td style={{ textAlign: 'right' }}>{p.score}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
|
|
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
|
|
|
|
{!headline && event.past.length === 0 && (
|
|
<EmptyState>This event has not been scheduled yet.</EmptyState>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
function Occurrences({ title, list, slug, timezone, past = false }) {
|
|
if (!list || list.length === 0) return null
|
|
return (
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
|
{title}
|
|
</h2>
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{list.map((o) => (
|
|
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
|
|
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
|
|
{/* Only a past occurrence gets its own link, and only when it has
|
|
results: on any other, `?run=` would change nothing a reader
|
|
could see. */}
|
|
{past && o.resultsPublishedAt && (
|
|
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
|
|
Results
|
|
</Link>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|