Add a reusable event calendar and put it on the front page
EventCalendar shows events on a month grid or as a list of the month. Multi-day events are lane-packed bars that break at week edges, series events appear on every meeting with their time, and clicking a day lists everything on it. Phones get the list. Visitors can narrow by scope, type, online only and search, all starting at "all"; a page can pin section, host or type through props, which hides that control. Migration 019 rebuilds front_page_sections to allow a 'calendar' band and slots it in after the retreats carousel, so it can be reordered, retitled or hidden from the Front page editor. useEvents drops its empty fallback so a failed request surfaces as an error rather than an empty list, and returns the scope list alongside the events for the calendar's scope filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
641ab167b0
commit
2e125b4629
10 changed files with 947 additions and 8 deletions
791
src/pages/sections/EventCalendar.tsx
Normal file
791
src/pages/sections/EventCalendar.tsx
Normal file
|
|
@ -0,0 +1,791 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CALENDAR
|
||||
|
||||
Every published event on a month grid, or as a list of the month.
|
||||
Self contained like EventListCards: give it a filter and it
|
||||
fetches, so it can sit on any page.
|
||||
|
||||
<EventCalendar /> everything
|
||||
<EventCalendar host="northwest" /> one host's calendar
|
||||
<EventCalendar section="national" /> one scope
|
||||
<EventCalendar type={["class", "workshop"]} controls={["search"]} />
|
||||
|
||||
Two layers of filtering, and they answer different questions:
|
||||
|
||||
props what this page's calendar is about. Pinned; the
|
||||
visitor can't widen them, and the control for a
|
||||
pinned dimension doesn't render.
|
||||
controls what the visitor can narrow by within that: scope,
|
||||
type, online only, search. All four by default,
|
||||
every one starting at "all".
|
||||
|
||||
What lands on a day:
|
||||
|
||||
one-off every day from starts_on to ends_on, drawn as one
|
||||
bar across the days it spans, broken at the week
|
||||
edge and marked as continuing
|
||||
series every meeting the schedule produces in view (see
|
||||
eventSeries.ts), one day each, with its time
|
||||
undated nowhere — there's no day to put it on. Counted
|
||||
under the grid so it doesn't vanish without a word.
|
||||
|
||||
Bars are laid out per week in lanes, so a long event keeps its
|
||||
row across the days it covers. MAX_LANES rows show; a day with
|
||||
more says "+N more", and clicking any day lists everything on it
|
||||
below the grid.
|
||||
|
||||
Below md the grid would be seven unreadable slivers, so the month
|
||||
view shows the list there instead. The toggle still works; it
|
||||
just has one answer on a phone.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { typesPresent, useEvents } from '../../data/eventData.js'
|
||||
import type { EventFilter } from '../../data/eventData.js'
|
||||
import { EVENT_TYPES, eventTypeLabel } from '../../lib/eventTypes.ts'
|
||||
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
|
||||
import { eventHref } from '../../lib/hrefs.ts'
|
||||
import type { EventListItem } from '../../lib/useContent.ts'
|
||||
|
||||
export type CalendarControl = 'scope' | 'type' | 'online' | 'search'
|
||||
export type CalendarView = 'month' | 'list'
|
||||
|
||||
const ALL_CONTROLS: CalendarControl[] = ['scope', 'type', 'online', 'search']
|
||||
|
||||
/* Bar rows drawn per week before a day collapses to "+N more". */
|
||||
const MAX_LANES = 3
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const INK = '#073d4a'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
type EventCalendarProps = EventFilter & {
|
||||
accent?: string
|
||||
/** Which visitor controls render. A pinned prop hides its own. */
|
||||
controls?: CalendarControl[]
|
||||
defaultView?: CalendarView
|
||||
}
|
||||
|
||||
/* One appearance of an event on the calendar: a whole one-off, or a
|
||||
single meeting of a series. Dates are inclusive 'YYYY-MM-DD'. */
|
||||
type Occurrence = {
|
||||
key: string
|
||||
event: EventListItem
|
||||
start: string
|
||||
end: string
|
||||
time: string | null
|
||||
}
|
||||
|
||||
type Segment = Occurrence & {
|
||||
col: number
|
||||
span: number
|
||||
lane: number
|
||||
continuesBefore: boolean
|
||||
continuesAfter: boolean
|
||||
}
|
||||
|
||||
/* ── Dates ───────────────────────────────────────────────────── */
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
|
||||
function parse(date: string): Date {
|
||||
const [y, m, d] = date.split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
const addDays = (date: string, n: number) => {
|
||||
const d = parse(date)
|
||||
d.setDate(d.getDate() + n)
|
||||
return iso(d)
|
||||
}
|
||||
|
||||
const dayDiff = (a: string, b: string) =>
|
||||
Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000)
|
||||
|
||||
const monthKey = (date: string) => date.slice(0, 7)
|
||||
|
||||
/* The six Sunday-started weeks that cover a month. */
|
||||
function gridFor(month: string): string[][] {
|
||||
const first = parse(`${month}-01`)
|
||||
const start = addDays(iso(first), -first.getDay())
|
||||
return Array.from({ length: 6 }, (_, w) =>
|
||||
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
|
||||
)
|
||||
}
|
||||
|
||||
const monthTitle = (month: string) =>
|
||||
parse(`${month}-01`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
const shiftMonth = (month: string, n: number) => {
|
||||
const d = parse(`${month}-01`)
|
||||
d.setMonth(d.getMonth() + n)
|
||||
return monthKey(iso(d))
|
||||
}
|
||||
|
||||
const longDay = (date: string) =>
|
||||
parse(date).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
function rangeLabel(start: string, end: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
|
||||
if (start === end) return parse(start).toLocaleDateString(undefined, opts)
|
||||
return `${parse(start).toLocaleDateString(undefined, opts)} – ${parse(end).toLocaleDateString(undefined, opts)}`
|
||||
}
|
||||
|
||||
/* ── Occurrences ─────────────────────────────────────────────── */
|
||||
|
||||
function occurrencesIn(events: EventListItem[], from: string, to: string): Occurrence[] {
|
||||
const out: Occurrence[] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.starts_on) continue
|
||||
|
||||
if (event.series) {
|
||||
const time = seriesTimes(event.series)
|
||||
for (const date of occurrencesBetween(event.series, event.starts_on, event.ends_on, from, to)) {
|
||||
out.push({ key: `${event.id}@${date}`, event, start: date, end: date, time })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const start = event.starts_on
|
||||
const end = event.ends_on && event.ends_on >= start ? event.ends_on : start
|
||||
if (end < from || start > to) continue
|
||||
out.push({ key: event.id, event, start, end, time: null })
|
||||
}
|
||||
|
||||
// Longest first within a day, so multi-day bars claim the top lanes.
|
||||
return out.sort(
|
||||
(a, b) => a.start.localeCompare(b.start) || dayDiff(b.start, b.end) - dayDiff(a.start, a.end),
|
||||
)
|
||||
}
|
||||
|
||||
/* Greedy lane packing for one week: each segment takes the first
|
||||
lane whose last occupant ended before it starts. */
|
||||
function layoutWeek(week: string[], occurrences: Occurrence[]): Segment[] {
|
||||
const first = week[0]
|
||||
const last = week[6]
|
||||
const laneEnds: number[] = []
|
||||
const segments: Segment[] = []
|
||||
|
||||
for (const occ of occurrences) {
|
||||
if (occ.end < first || occ.start > last) continue
|
||||
const start = occ.start < first ? first : occ.start
|
||||
const end = occ.end > last ? last : occ.end
|
||||
const col = dayDiff(first, start)
|
||||
const span = dayDiff(start, end) + 1
|
||||
|
||||
let lane = laneEnds.findIndex((endCol) => endCol < col)
|
||||
if (lane === -1) lane = laneEnds.length
|
||||
laneEnds[lane] = col + span - 1
|
||||
|
||||
segments.push({
|
||||
...occ,
|
||||
col,
|
||||
span,
|
||||
lane,
|
||||
continuesBefore: occ.start < first,
|
||||
continuesAfter: occ.end > last,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/* The next date after `after` that any of these events lands on. */
|
||||
function nextDateAfter(events: EventListItem[], after: string): string | null {
|
||||
let best: string | null = null
|
||||
const from = addDays(after, 1)
|
||||
for (const event of events) {
|
||||
if (!event.starts_on) continue
|
||||
const date = event.series
|
||||
? firstOccurrenceFrom(event.series, event.starts_on, event.ends_on, from)
|
||||
: event.starts_on >= from
|
||||
? event.starts_on
|
||||
: null
|
||||
if (date && (!best || date < best)) best = date
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
|
||||
export default function EventCalendar({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
accent = TEAL,
|
||||
controls = ALL_CONTROLS,
|
||||
defaultView = 'month',
|
||||
}: EventCalendarProps) {
|
||||
const { events: pinned, sections, loading, error } = useEvents({ section, host, status, type })
|
||||
|
||||
const today = iso(new Date())
|
||||
const [month, setMonth] = useState(monthKey(today))
|
||||
const [view, setView] = useState<CalendarView>(defaultView)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const [scope, setScope] = useState('')
|
||||
const [kind, setKind] = useState('')
|
||||
const [onlineOnly, setOnlineOnly] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const show = (control: CalendarControl) => controls.includes(control)
|
||||
const showScope = show('scope') && !section
|
||||
const showType = show('type') && !type
|
||||
|
||||
const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned])
|
||||
const scopes = useMemo(() => {
|
||||
const present = new Set(pinned.map((e) => e.section_id))
|
||||
return sections.filter((s) => present.has(s.id))
|
||||
}, [pinned, sections])
|
||||
|
||||
const events = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
return pinned.filter((e) => {
|
||||
if (scope && e.section_id !== scope) return false
|
||||
if (kind && e.event_type !== kind) return false
|
||||
if (onlineOnly && !e.is_online) return false
|
||||
if (needle) {
|
||||
const haystack = [
|
||||
e.title,
|
||||
e.theme,
|
||||
e.location_label,
|
||||
e.locality,
|
||||
...(e.hosts ?? []).map((h) => h.name),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (!haystack.includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [pinned, scope, kind, onlineOnly, query])
|
||||
|
||||
const weeks = useMemo(() => gridFor(month), [month])
|
||||
const gridFrom = weeks[0][0]
|
||||
const gridTo = weeks[5][6]
|
||||
const monthFrom = `${month}-01`
|
||||
const monthTo = addDays(`${shiftMonth(month, 1)}-01`, -1)
|
||||
|
||||
const occurrences = useMemo(
|
||||
() => occurrencesIn(events, gridFrom, gridTo),
|
||||
[events, gridFrom, gridTo],
|
||||
)
|
||||
const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo)
|
||||
const undated = events.filter((e) => !e.starts_on).length
|
||||
const filtering = Boolean(scope || kind || onlineOnly || query)
|
||||
const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
|
||||
|
||||
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
|
||||
|
||||
const go = (target: string) => {
|
||||
setMonth(target)
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setScope('')
|
||||
setKind('')
|
||||
setOnlineOnly(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6" style={{ color: INK }}>
|
||||
{/* ── Month navigation and view ── */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavButton label="Previous month" onClick={() => go(shiftMonth(month, -1))} accent={accent}>
|
||||
‹
|
||||
</NavButton>
|
||||
<NavButton label="Next month" onClick={() => go(shiftMonth(month, 1))} accent={accent}>
|
||||
›
|
||||
</NavButton>
|
||||
</div>
|
||||
|
||||
<h3 className="min-w-[11rem] font-display text-2xl font-bold" aria-live="polite">
|
||||
{monthTitle(month)}
|
||||
</h3>
|
||||
|
||||
{month !== monthKey(today) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(monthKey(today))}
|
||||
className="rounded-full border px-3 py-1 text-sm font-semibold hover:bg-white"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ml-auto hidden overflow-hidden rounded-full border md:flex"
|
||||
style={{ borderColor: accent }}
|
||||
role="group"
|
||||
aria-label="Calendar view"
|
||||
>
|
||||
{(['month', 'list'] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setView(option)}
|
||||
aria-pressed={view === option}
|
||||
className="px-4 py-1.5 text-sm font-semibold capitalize transition-colors"
|
||||
style={
|
||||
view === option
|
||||
? { background: accent, color: '#ffffff' }
|
||||
: { color: accent }
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filters ── */}
|
||||
{(showScope || showType || show('online') || show('search')) && (
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
{showScope && scopes.length > 1 && (
|
||||
<FilterSelect
|
||||
label="Scope"
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
all="All scopes"
|
||||
options={scopes.map((s) => [s.id, s.name])}
|
||||
/>
|
||||
)}
|
||||
{showType && types.length > 1 && (
|
||||
<FilterSelect
|
||||
label="Type"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
all="All types"
|
||||
options={types.map((t) => [t.id, t.plural])}
|
||||
/>
|
||||
)}
|
||||
{show('online') && (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlineOnly}
|
||||
onChange={(e) => setOnlineOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded"
|
||||
style={{ accentColor: accent }}
|
||||
/>
|
||||
Online only
|
||||
</label>
|
||||
)}
|
||||
{show('search') && (
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search events"
|
||||
aria-label="Search events"
|
||||
className="min-w-[12rem] flex-1 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0] md:max-w-xs"
|
||||
/>
|
||||
)}
|
||||
{filtering && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="text-sm font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="mt-6">
|
||||
{error ? (
|
||||
<p className="rounded-2xl bg-white p-6 text-[#b3261e]">
|
||||
Couldn’t load events. {error.message}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="h-[32rem] animate-pulse rounded-3xl bg-white/70" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{view === 'month' && (
|
||||
<div className="hidden md:block">
|
||||
<MonthGrid
|
||||
weeks={weeks}
|
||||
month={month}
|
||||
today={today}
|
||||
occurrences={occurrences}
|
||||
selected={selected}
|
||||
onSelect={(date) => setSelected((s) => (s === date ? null : date))}
|
||||
accent={accent}
|
||||
/>
|
||||
{selected && (
|
||||
<DayPanel
|
||||
date={selected}
|
||||
items={onDay(selected)}
|
||||
accent={accent}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={view === 'month' ? 'md:hidden' : ''}>
|
||||
<MonthList items={inMonth} monthFrom={monthFrom} accent={accent} />
|
||||
</div>
|
||||
|
||||
{inMonth.length === 0 && (
|
||||
<div className="mt-4 text-center text-sm" style={{ color: BODY }}>
|
||||
{next ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(monthKey(next))}
|
||||
className="font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Jump to the next event, {monthTitle(monthKey(next))} →
|
||||
</button>
|
||||
) : filtering ? (
|
||||
'Nothing matches these filters.'
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{undated > 0 && (
|
||||
<p className="mt-4 text-center text-xs" style={{ color: BODY }}>
|
||||
{undated === 1 ? '1 event has' : `${undated} events have`} no dates yet, so{' '}
|
||||
{undated === 1 ? 'isn’t' : 'aren’t'} on the calendar.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Month grid ──────────────────────────────────────────────── */
|
||||
|
||||
type MonthGridProps = {
|
||||
weeks: string[][]
|
||||
month: string
|
||||
today: string
|
||||
occurrences: Occurrence[]
|
||||
selected: string | null
|
||||
onSelect: (date: string) => void
|
||||
accent: string
|
||||
}
|
||||
|
||||
function MonthGrid({ weeks, month, today, occurrences, selected, onSelect, accent }: MonthGridProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-[#138ba0]/15 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-7 border-b border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="px-3 py-2 text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: BODY }}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{weeks.map((week) => {
|
||||
const segments = layoutWeek(week, occurrences)
|
||||
const visible = segments.filter((s) => s.lane < MAX_LANES)
|
||||
const hiddenOn = (col: number) =>
|
||||
segments.filter((s) => s.lane >= MAX_LANES && s.col <= col && s.col + s.span > col).length
|
||||
const countOn = (col: number) =>
|
||||
segments.filter((s) => s.col <= col && s.col + s.span > col).length
|
||||
|
||||
return (
|
||||
<div key={week[0]} className="relative min-h-[8rem] border-b border-[#138ba0]/10 last:border-b-0">
|
||||
{/* Day cells: the click targets, and the numbers. */}
|
||||
<div className="absolute inset-0 grid grid-cols-7">
|
||||
{week.map((date, col) => {
|
||||
const outside = monthKey(date) !== month
|
||||
const isToday = date === today
|
||||
const isSelected = date === selected
|
||||
const count = countOn(col)
|
||||
const hidden = hiddenOn(col)
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
onClick={() => onSelect(date)}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${longDay(date)}${count ? `, ${count} event${count === 1 ? '' : 's'}` : ''}`}
|
||||
className="relative flex flex-col items-start border-r border-[#138ba0]/10 p-2 text-left transition-colors last:border-r-0 hover:bg-[#f6fbfc]"
|
||||
style={{
|
||||
background: isSelected ? `${accent}14` : outside ? '#fbfdfd' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: '#ffffff' }
|
||||
: { color: outside ? '#b8c6c9' : INK }
|
||||
}
|
||||
>
|
||||
{Number(date.slice(8))}
|
||||
</span>
|
||||
{hidden > 0 && (
|
||||
<span className="mt-auto text-xs font-semibold" style={{ color: accent }}>
|
||||
+{hidden} more
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bars, laid over the cells. Only the bars take clicks. */}
|
||||
<div
|
||||
className="pointer-events-none relative grid grid-cols-7 gap-y-1 pb-7 pt-10"
|
||||
style={{ gridTemplateRows: `repeat(${MAX_LANES}, 1.5rem)` }}
|
||||
>
|
||||
{visible.map((seg) => (
|
||||
<Bar key={`${seg.key}-${week[0]}`} seg={seg} accent={accent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bar({ seg, accent }: { seg: Segment; accent: string }) {
|
||||
const color = seg.event.color || accent
|
||||
const cancelled = seg.event.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={eventHref(seg.event.id)}
|
||||
title={`${seg.event.title}${seg.time ? ` · ${seg.time}` : ''}`}
|
||||
className={`pointer-events-auto flex items-center gap-1 truncate px-2 text-xs font-semibold text-white transition-[filter] hover:brightness-110 ${
|
||||
seg.continuesBefore ? 'ml-0 rounded-l-none' : 'ml-1 rounded-l-md'
|
||||
} ${seg.continuesAfter ? 'mr-0 rounded-r-none' : 'mr-1 rounded-r-md'} ${
|
||||
cancelled ? 'line-through opacity-60' : ''
|
||||
}`}
|
||||
style={{
|
||||
gridColumn: `${seg.col + 1} / span ${seg.span}`,
|
||||
gridRow: seg.lane + 1,
|
||||
background: color,
|
||||
}}
|
||||
>
|
||||
{seg.continuesBefore && <span aria-hidden="true">←</span>}
|
||||
<span className="truncate">
|
||||
{seg.time && seg.span === 1 && <span className="font-normal opacity-85">{seg.time.split(' – ')[0]} </span>}
|
||||
{seg.event.title}
|
||||
</span>
|
||||
{seg.continuesAfter && (
|
||||
<span className="ml-auto" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The selected day ────────────────────────────────────────── */
|
||||
|
||||
function DayPanel({
|
||||
date,
|
||||
items,
|
||||
accent,
|
||||
onClose,
|
||||
}: {
|
||||
date: string
|
||||
items: Occurrence[]
|
||||
accent: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 rounded-3xl border bg-white p-6" style={{ borderColor: `${accent}40` }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h4 className="font-display text-lg font-bold">{longDay(date)}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto text-sm font-semibold hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm" style={{ color: BODY }}>
|
||||
Nothing on this day.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
<EventLine item={item} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── List view ───────────────────────────────────────────────── */
|
||||
|
||||
/* The month's occurrences by day. An event that began last month
|
||||
files under the 1st, where it's still on. */
|
||||
function MonthList({
|
||||
items,
|
||||
monthFrom,
|
||||
accent,
|
||||
}: {
|
||||
items: Occurrence[]
|
||||
monthFrom: string
|
||||
accent: string
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="rounded-3xl bg-white p-10 text-center" style={{ color: BODY }}>
|
||||
Nothing on the calendar this month.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const byDay = new Map<string, Occurrence[]>()
|
||||
for (const item of items) {
|
||||
const day = item.start < monthFrom ? monthFrom : item.start
|
||||
const list = byDay.get(day)
|
||||
if (list) list.push(item)
|
||||
else byDay.set(day, [item])
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-4">
|
||||
{[...byDay.entries()].map(([day, list]) => {
|
||||
const d = parse(day)
|
||||
return (
|
||||
<li key={day} className="flex gap-5 rounded-3xl bg-white p-5 shadow-sm">
|
||||
<div className="w-14 shrink-0 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: accent }}>
|
||||
{d.toLocaleDateString(undefined, { weekday: 'short' })}
|
||||
</p>
|
||||
<p className="font-display text-3xl font-extrabold leading-none">{d.getDate()}</p>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{list.map((item) => (
|
||||
<li key={item.key}>
|
||||
<EventLine item={item} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/* One occurrence as a line of text, shared by the day panel and the
|
||||
list so the two describe an event the same way. */
|
||||
function EventLine({ item, accent }: { item: Occurrence; accent: string }) {
|
||||
const { event } = item
|
||||
const color = event.color || accent
|
||||
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||
const cancelled = event.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={eventHref(event.id)}
|
||||
className="group flex items-start gap-3 rounded-xl p-2 transition-colors hover:bg-[#f6fbfc]"
|
||||
>
|
||||
<span className="mt-1.5 h-3 w-3 shrink-0 rounded-full" style={{ background: color }} aria-hidden="true" />
|
||||
<span className="min-w-0">
|
||||
<span className={`block font-semibold group-hover:underline ${cancelled ? 'line-through' : ''}`}>
|
||||
{event.title}
|
||||
{cancelled && <span className="ml-2 text-xs font-bold uppercase text-[#b3261e] no-underline">Cancelled</span>}
|
||||
</span>
|
||||
<span className="block text-sm" style={{ color: BODY }}>
|
||||
{[
|
||||
item.time ?? (item.start !== item.end ? rangeLabel(item.start, item.end) : null),
|
||||
eventTypeLabel(event.event_type),
|
||||
where,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Controls ────────────────────────────────────────────────── */
|
||||
|
||||
function NavButton({
|
||||
label,
|
||||
onClick,
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
accent: string
|
||||
children: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border text-xl transition-colors hover:bg-white"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
all,
|
||||
options,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
all: string
|
||||
options: Array<[string, string]>
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
className="rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0]"
|
||||
>
|
||||
<option value="">{all}</option>
|
||||
{options.map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
28
src/pages/sections/home/CalendarBand.tsx
Normal file
28
src/pages/sections/home/CalendarBand.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
CALENDAR BAND
|
||||
|
||||
EventCalendar on the front page: every scope and every type, all
|
||||
four visitor filters, starting on this month. The same component
|
||||
can go on any page with a narrower filter — a region's page would
|
||||
pass host, a classes page would pass type — and this file only
|
||||
adds the front page's heading around it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import EventCalendar from '../EventCalendar.tsx'
|
||||
|
||||
type CalendarBandProps = { id: string; title: string; blurb?: string | null }
|
||||
|
||||
export default function CalendarBand({ id, title, blurb }: CalendarBandProps) {
|
||||
return (
|
||||
<section id={id} className="py-24" style={{ background: '#f6fbfc' }}>
|
||||
<div className="mx-auto mb-10 max-w-6xl px-6">
|
||||
<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>
|
||||
|
||||
<EventCalendar />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue