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
|
|
@ -716,7 +716,7 @@ const frontPage = {
|
||||||
columns: [
|
columns: [
|
||||||
enumeration(
|
enumeration(
|
||||||
"section",
|
"section",
|
||||||
["countdown", "retreats", "stats", "timeline", "connect"],
|
["countdown", "retreats", "calendar", "stats", "timeline", "connect"],
|
||||||
{ required: true },
|
{ required: true },
|
||||||
),
|
),
|
||||||
text("title"),
|
text("title"),
|
||||||
|
|
|
||||||
61
server/src/migrations/019_front_page_calendar.sql
Normal file
61
server/src/migrations/019_front_page_calendar.sql
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- FRONT PAGE: calendar band
|
||||||
|
--
|
||||||
|
-- Adds 'calendar' to the sections the front page can draw. The key
|
||||||
|
-- is a CHECK, and SQLite can't alter a CHECK in place, so the table
|
||||||
|
-- is rebuilt: new table, copy, drop, rename.
|
||||||
|
--
|
||||||
|
-- No PRAGMA foreign_keys dance. front_page_sections only points out
|
||||||
|
-- (at front_page); nothing points in, so dropping the old table
|
||||||
|
-- cascades into nothing, and the copy keeps every page_id valid.
|
||||||
|
--
|
||||||
|
-- The new band is inserted straight after the retreats carousel,
|
||||||
|
-- where "what's on" reads naturally, by shifting everything below it
|
||||||
|
-- down one. If retreats was removed on this box, it goes last.
|
||||||
|
--
|
||||||
|
-- Adding another section later is the same three steps: this CHECK,
|
||||||
|
-- the enum in both descriptor halves, and SECTIONS in Home.tsx.
|
||||||
|
--
|
||||||
|
-- No BEGIN...END in this file.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE front_page_sections_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
section TEXT NOT NULL
|
||||||
|
CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats',
|
||||||
|
'timeline', 'connect')),
|
||||||
|
title TEXT,
|
||||||
|
blurb TEXT,
|
||||||
|
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
|
||||||
|
UNIQUE (page_id, section)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden)
|
||||||
|
SELECT id, page_id, sort_order, section, title, blurb, is_hidden
|
||||||
|
FROM front_page_sections;
|
||||||
|
|
||||||
|
DROP TABLE front_page_sections;
|
||||||
|
|
||||||
|
ALTER TABLE front_page_sections_new RENAME TO front_page_sections;
|
||||||
|
|
||||||
|
UPDATE front_page_sections
|
||||||
|
SET sort_order = sort_order + 1
|
||||||
|
WHERE page_id = 'home'
|
||||||
|
AND sort_order > COALESCE(
|
||||||
|
(SELECT sort_order FROM front_page_sections
|
||||||
|
WHERE page_id = 'home' AND section = 'retreats'),
|
||||||
|
(SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home'));
|
||||||
|
|
||||||
|
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb)
|
||||||
|
SELECT 'home',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT sort_order + 1 FROM front_page_sections
|
||||||
|
WHERE page_id = 'home' AND section = 'retreats'),
|
||||||
|
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections
|
||||||
|
WHERE page_id = 'home')),
|
||||||
|
'calendar',
|
||||||
|
'What''s on',
|
||||||
|
'Every gathering, class and meeting in one place.'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home');
|
||||||
4
src/data/eventData.d.ts
vendored
4
src/data/eventData.d.ts
vendored
|
|
@ -2,7 +2,7 @@
|
||||||
server/src/routes/content.js sends them. */
|
server/src/routes/content.js sends them. */
|
||||||
|
|
||||||
import type { EventType } from "../lib/eventTypes.ts";
|
import type { EventType } from "../lib/eventTypes.ts";
|
||||||
import type { EventListItem } from "../lib/useContent.ts";
|
import type { EventListItem, EventSection } from "../lib/useContent.ts";
|
||||||
|
|
||||||
export type EventFilter = {
|
export type EventFilter = {
|
||||||
section?: string;
|
section?: string;
|
||||||
|
|
@ -13,6 +13,8 @@ export type EventFilter = {
|
||||||
|
|
||||||
export declare function useEvents(filter?: EventFilter): {
|
export declare function useEvents(filter?: EventFilter): {
|
||||||
events: EventListItem[];
|
events: EventListItem[];
|
||||||
|
/** event_sections, in scope order. Empty until loaded. */
|
||||||
|
sections: EventSection[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
belongs to, the type is what kind of gathering it is. A regional
|
belongs to, the type is what kind of gathering it is. A regional
|
||||||
class matches both { section: "regional" } and { type: "class" }.
|
class matches both { section: "regional" } and { type: "class" }.
|
||||||
|
|
||||||
|
The event_sections rows (the scope list) come back alongside, for
|
||||||
|
anything that offers a scope filter.
|
||||||
|
|
||||||
|
No fallback. An empty list on a failed request would read as
|
||||||
|
"nothing scheduled" when the truth is "the server is down", so
|
||||||
|
the error comes back and each caller says so.
|
||||||
|
|
||||||
Filtering here rather than in the query keeps the endpoint to
|
Filtering here rather than in the query keeps the endpoint to
|
||||||
one cached response. At a few dozen events that's the right
|
one cached response. At a few dozen events that's the right
|
||||||
trade; if the list ever runs to hundreds, move the filters into
|
trade; if the list ever runs to hundreds, move the filters into
|
||||||
|
|
@ -28,12 +35,15 @@ import { useMemo } from "react";
|
||||||
|
|
||||||
import { useResource } from "../lib/useResource.js";
|
import { useResource } from "../lib/useResource.js";
|
||||||
|
|
||||||
const EMPTY = { events: [] };
|
/* One shared empty list, so a memo keyed on `sections` doesn't
|
||||||
|
restart on every render before the data arrives. */
|
||||||
|
const NO_SECTIONS = [];
|
||||||
|
|
||||||
export function useEvents({ section, host, status, type } = {}) {
|
export function useEvents({ section, host, status, type } = {}) {
|
||||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
const { data, error, loading } = useResource("/events");
|
||||||
|
|
||||||
const all = data?.events;
|
const all = data?.events;
|
||||||
|
const sections = data?.sections ?? NO_SECTIONS;
|
||||||
|
|
||||||
/* An array prop is a new identity on every render, which would
|
/* An array prop is a new identity on every render, which would
|
||||||
restart the memo each time. Joining it gives the dependency
|
restart the memo each time. Joining it gives the dependency
|
||||||
|
|
@ -55,7 +65,7 @@ export function useEvents({ section, host, status, type } = {}) {
|
||||||
return list;
|
return list;
|
||||||
}, [all, section, host, status, typeKey]);
|
}, [all, section, host, status, typeKey]);
|
||||||
|
|
||||||
return { events, loading, error };
|
return { events, sections, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||||
|
|
|
||||||
|
|
@ -948,13 +948,14 @@ const timeline = {
|
||||||
the page. Photos and the livestream are editable whatever the
|
the page. Photos and the livestream are editable whatever the
|
||||||
mode, so either can be ready before the switch is flipped.
|
mode, so either can be ready before the switch is flipped.
|
||||||
|
|
||||||
Section keys and stat sources are the CHECK lists in migration
|
Section keys and stat sources are the CHECK lists in migrations
|
||||||
017. The labels here are what the admin reads; the values are
|
017 and 019. The labels here are what the admin reads; the values are
|
||||||
what the page and the API key on. */
|
what the page and the API key on. */
|
||||||
|
|
||||||
const FRONT_PAGE_SECTIONS = [
|
const FRONT_PAGE_SECTIONS = [
|
||||||
["countdown", "Countdown to the next event"],
|
["countdown", "Countdown to the next event"],
|
||||||
["retreats", "National Retreats carousel"],
|
["retreats", "National Retreats carousel"],
|
||||||
|
["calendar", "Event calendar"],
|
||||||
["stats", "Numbers"],
|
["stats", "Numbers"],
|
||||||
["timeline", "Featured timeline"],
|
["timeline", "Featured timeline"],
|
||||||
["connect", "Find your way in"],
|
["connect", "Find your way in"],
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,39 @@ export function upcomingOccurrences(
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every meeting between two dates, inclusive, in order. For a
|
||||||
|
* calendar page: `from` and `to` are the visible range. */
|
||||||
|
export function occurrencesBetween(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn: string | null | undefined,
|
||||||
|
endsOn: string | null | undefined,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): string[] {
|
||||||
|
if (!series) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||||
|
if (date > to) break
|
||||||
|
if (date >= from) out.push(date)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first meeting on or after `from`, or null if the series has
|
||||||
|
* ended by then. */
|
||||||
|
export function firstOccurrenceFrom(
|
||||||
|
series: EventSeries | null | undefined,
|
||||||
|
startsOn: string | null | undefined,
|
||||||
|
endsOn: string | null | undefined,
|
||||||
|
from: string,
|
||||||
|
): string | null {
|
||||||
|
if (!series) return null
|
||||||
|
for (const date of occurrences(series, startsOn, endsOn)) {
|
||||||
|
if (date >= from) return date
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Words ───────────────────────────────────────────────────── */
|
/* ── Words ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
|
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,13 @@ import type { EventSeries } from './eventSeries.ts'
|
||||||
export type HeroMode = 'brand' | 'photos' | 'livestream'
|
export type HeroMode = 'brand' | 'photos' | 'livestream'
|
||||||
|
|
||||||
/** The CHECK list on front_page_sections.section. */
|
/** The CHECK list on front_page_sections.section. */
|
||||||
export type FrontPageSectionKey = 'countdown' | 'retreats' | 'stats' | 'timeline' | 'connect'
|
export type FrontPageSectionKey =
|
||||||
|
| 'countdown'
|
||||||
|
| 'retreats'
|
||||||
|
| 'calendar'
|
||||||
|
| 'stats'
|
||||||
|
| 'timeline'
|
||||||
|
| 'connect'
|
||||||
|
|
||||||
export type HeroButton = { label: string; url: string }
|
export type HeroButton = { label: string; url: string }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type { ReactNode } from 'react'
|
||||||
import HeroStage from './sections/home/HeroStage.tsx'
|
import HeroStage from './sections/home/HeroStage.tsx'
|
||||||
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
|
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
|
||||||
import RetreatsBand from './sections/home/RetreatsBand.tsx'
|
import RetreatsBand from './sections/home/RetreatsBand.tsx'
|
||||||
|
import CalendarBand from './sections/home/CalendarBand.tsx'
|
||||||
import StatsBand from './sections/home/StatsBand.tsx'
|
import StatsBand from './sections/home/StatsBand.tsx'
|
||||||
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
|
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
|
||||||
import Pathfinder from './sections/home/Pathfinder.tsx'
|
import Pathfinder from './sections/home/Pathfinder.tsx'
|
||||||
|
|
@ -64,6 +65,12 @@ const SECTIONS: Record<
|
||||||
<RetreatsBand id="retreats" title={title} blurb={section.blurb} />
|
<RetreatsBand id="retreats" title={title} blurb={section.blurb} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
calendar: {
|
||||||
|
title: 'What’s on',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<CalendarBand id="calendar" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
title: 'By the numbers',
|
title: 'By the numbers',
|
||||||
render: ({ page, section, title }) =>
|
render: ({ page, section, title }) =>
|
||||||
|
|
|
||||||
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