- Declare the --ig-fill custom property on React's CSSProperties once, instead of casting the style prop in Footer, Home and EventList-Cards. - Type Footer's Get_In_Touch links as internal or external, so the external branch type-checks. - Pass undefined rather than null for an absent gradient (Home) and Instagram URL (EventList-Cards). tsc --noEmit is now clean, with and without --noImplicitAny. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
798 lines
30 KiB
TypeScript
798 lines
30 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import {
|
||
splitByStatus,
|
||
typesPresent,
|
||
useEvents,
|
||
type EventFilter,
|
||
} from "../../data/eventData.js";
|
||
import { eventHref } from "../../lib/hrefs.ts";
|
||
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
|
||
import type { EventListItem } from "../../lib/useContent.ts";
|
||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
EVENT LIST — CARDS
|
||
|
||
A band of event cards, as a peek carousel or a grid. Self
|
||
contained: give it a filter and it fetches, so the same section
|
||
appears three times on Retreats with a different `section` each
|
||
time, and could appear on a region's page with `host` instead.
|
||
|
||
<EventListCards section="national" view="carousel" />
|
||
<EventListCards host="northwest" view="grid" />
|
||
<EventListCards type={["class", "workshop"]} view="grid" />
|
||
|
||
`view` and `accent` come from the page's section manifest.
|
||
|
||
`type` pre-filters the band the way `section` and `host` do. Left
|
||
off, the band takes every kind it finds and grows a row of chips
|
||
to narrow by — but only once it holds more than one, so a band of
|
||
nothing but retreats shows no control at all.
|
||
═══════════════════════════════════════════════════════════════ */
|
||
|
||
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
|
||
eager: true,
|
||
import: "default",
|
||
});
|
||
|
||
const LOGOS = Object.fromEntries(
|
||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||
);
|
||
|
||
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
|
||
|
||
/* Last resort only. The API already falls back to the host
|
||
organization's logo when an event doesn't name its own, so this
|
||
fires only for an event with no host at all. */
|
||
const DEFAULT_ORG_LOGO = null;
|
||
|
||
/* An <img> that removes itself if the file 404s. */
|
||
function Logo({
|
||
file,
|
||
alt = "",
|
||
className,
|
||
}: {
|
||
file: string | null | undefined;
|
||
alt?: string;
|
||
className?: string;
|
||
}) {
|
||
const [failed, setFailed] = useState(false);
|
||
const src = logoSrc(file);
|
||
if (!src || failed) return null;
|
||
return (
|
||
<img
|
||
src={src}
|
||
alt={alt}
|
||
className={className}
|
||
onError={() => setFailed(true)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const TEAL = "#138ba0"; // last-resort card color
|
||
const CARD_BG = "#ffffff"; // sits under every card, gradient or not
|
||
|
||
/* ── Grid view ────────────────────────────────────────────────
|
||
The grid fits as many columns as the screen allows, with no
|
||
breakpoints: each card is at least CARD_MIN wide, and the
|
||
columns share whatever space is left over.
|
||
CARD_MIN narrowest a card may get before dropping a column
|
||
MAX_COLS ceiling on columns, so cards don't get absurd on
|
||
very wide monitors. GRID_MAX is derived from it.
|
||
───────────────────────────────────────────────────────────── */
|
||
const CARD_MIN = "32rem";
|
||
const MAX_COLS = 4;
|
||
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
|
||
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
|
||
|
||
/* Collapsed past-events strip: how much shows, and the downward
|
||
fade applied while it's collapsed. */
|
||
const PAST_PEEK = "10rem";
|
||
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
|
||
|
||
/* ── Carousel sizing ──────────────────────────────────────────
|
||
CARD the card itself
|
||
GAP space between cards — raise to push neighbors out
|
||
FADE_DIST how far past the card's edge neighbors fade to nothing
|
||
───────────────────────────────────────────────────────────── */
|
||
const CARD = "min(48rem, 90vw)";
|
||
const GAP = "5rem";
|
||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
||
const FADE_DIST = "18rem";
|
||
|
||
const EDGE_FADE = `linear-gradient(to right,
|
||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
||
black calc(50% - ${HALF_SLIDE}),
|
||
black calc(50% + ${HALF_SLIDE}),
|
||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||
|
||
/* Card layout CSS lives in index.css under the .ev- prefix. */
|
||
|
||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
className="ig-icon w-6 h-6"
|
||
style={{ "--ig-fill": `url(#${id})` }}
|
||
>
|
||
<defs>
|
||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||
<stop offset="0%" stopColor="#FEDA75" />
|
||
<stop offset="25%" stopColor="#FA7E1E" />
|
||
<stop offset="50%" stopColor="#D62976" />
|
||
<stop offset="75%" stopColor="#962FBF" />
|
||
<stop offset="100%" stopColor="#4F5BD5" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||
</svg>
|
||
);
|
||
|
||
/* The title is the way into the event's own page.
|
||
|
||
A link on the title rather than a wrapper around the whole card:
|
||
the footer already holds anchors, and an anchor inside an anchor
|
||
is invalid markup that every browser resolves by guessing. The
|
||
carousel has the same constraint — it needs the card's click to
|
||
mean "bring this one to the front" on anything that isn't the
|
||
active slide. */
|
||
function TitleLink({
|
||
ev,
|
||
linked,
|
||
children,
|
||
}: {
|
||
ev: Pick<EventListItem, "id">;
|
||
linked: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
if (!linked) return <>{children}</>;
|
||
return (
|
||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||
{children}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
EVENT CARD — one component, two sizes.
|
||
compact=false → the full card used in the carousel
|
||
compact=true → the grid card: details left, text right, and a
|
||
footer pinned to the bottom so every card in a
|
||
row is the same height with aligned buttons.
|
||
|
||
Exported because it takes an event and nothing else: a region's
|
||
page or a home strip can render one without the section around
|
||
it.
|
||
|
||
Fields arrive pre-resolved from the API — `color` is the event's
|
||
own or its host's, `status` is derived from the dates when it
|
||
isn't set — so nothing here reimplements those rules.
|
||
|
||
`linked` is the one thing a caller turns off: a card on the
|
||
event's own page shouldn't link to the page it's already on.
|
||
═══════════════════════════════════════════════════════════════ */
|
||
type CardProps = {
|
||
ev: EventListItem;
|
||
defaultColor?: string;
|
||
accent?: string;
|
||
compact?: boolean;
|
||
interactive?: boolean;
|
||
linked?: boolean;
|
||
showType?: boolean;
|
||
};
|
||
|
||
export function Card({
|
||
ev,
|
||
defaultColor = TEAL,
|
||
accent = TEAL,
|
||
compact = false,
|
||
interactive = true,
|
||
linked = true,
|
||
showType = false,
|
||
}: CardProps) {
|
||
const past = ev.status === "past";
|
||
const color = ev.color || defaultColor;
|
||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||
const eventLogo = ev.event_logo;
|
||
const links = ev.links ?? [];
|
||
const igHandle = ev.instagram || null;
|
||
const igUrl = igHandle
|
||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||
: undefined;
|
||
|
||
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||
on every card in a row of nothing but retreats is noise, and
|
||
the card can't tell on its own — it only ever sees one event. */
|
||
const typeBadge =
|
||
showType && ev.event_type ? (
|
||
<span
|
||
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
|
||
style={{ border: `1px solid ${color}`, color }}
|
||
>
|
||
{eventTypeLabel(ev.event_type)}
|
||
</span>
|
||
) : null;
|
||
|
||
/* An ordered array, so a card can carry one paragraph or five
|
||
without the component changing. */
|
||
const descriptions = (ev.description ?? []).map((text, i) => (
|
||
<p
|
||
key={i}
|
||
className={`leading-relaxed ${compact ? "text-base" : ""} ${
|
||
i > 0 ? "mt-2" : ""
|
||
}`}
|
||
>
|
||
{text}
|
||
</p>
|
||
));
|
||
|
||
return (
|
||
<div
|
||
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
|
||
compact ? "ev-card" : ""
|
||
}`}
|
||
style={{
|
||
border: `1px solid ${color}`,
|
||
background: ev.gradient ? `${ev.gradient}, ${CARD_BG}` : CARD_BG,
|
||
filter: past ? "saturate(0.75)" : "none",
|
||
pointerEvents: interactive ? "auto" : "none",
|
||
}}
|
||
>
|
||
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
|
||
{compact ? (
|
||
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
|
||
<div className="ev-grid mb-2">
|
||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||
<div className="ev-info">
|
||
{typeBadge}
|
||
<h3 className="text-3xl font-900 leading-tight">
|
||
<TitleLink ev={ev} linked={linked}>
|
||
{ev.title}
|
||
</TitleLink>
|
||
</h3>
|
||
{ev.theme && (
|
||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||
)}
|
||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||
{ev.location_label && (
|
||
<p className="text-xl">{ev.location_label}</p>
|
||
)}
|
||
</div>
|
||
<Logo
|
||
file={eventLogo}
|
||
alt={ev.title}
|
||
className="ev-image w-full h-auto object-contain"
|
||
/>
|
||
<div className="ev-desc">{descriptions}</div>
|
||
</div>
|
||
) : (
|
||
/* ── FULL: details left, large event logo right ── */
|
||
<>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||
<div className="md:col-span-2">
|
||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||
{typeBadge}
|
||
<h3 className="text-4xl font-900">
|
||
<TitleLink ev={ev} linked={linked}>
|
||
{ev.title}
|
||
</TitleLink>
|
||
</h3>
|
||
{ev.theme && (
|
||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||
)}
|
||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||
{ev.location_label && (
|
||
<p className="text-2xl">{ev.location_label}</p>
|
||
)}
|
||
</div>
|
||
<div className="md:col-span-1 flex items-center justify-end">
|
||
<Logo
|
||
file={eventLogo}
|
||
alt={ev.title}
|
||
className="w-full h-auto max-h-72 object-contain"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{descriptions}
|
||
</>
|
||
)}
|
||
|
||
{/* Footer — mt-auto pins the whole block to the bottom so
|
||
buttons line up across every card in a grid row.
|
||
|
||
Three registration states, as before: links to follow, a
|
||
past event, or an announcement still to come. What's new
|
||
is that all three end in the same row, because every
|
||
event now has a page and a past one is often the more
|
||
worth reading — speakers, awards, what actually
|
||
happened. The notice is what changes; the way in
|
||
doesn't. */}
|
||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||
{links.length === 0 && (
|
||
<p className="text-center font-600" style={{ color: accent }}>
|
||
{past
|
||
? "This event has concluded — thank you to everyone who joined us!"
|
||
: igHandle
|
||
? "Registration has not opened yet, follow our instagram for more details."
|
||
: "Registration has not opened yet — check back soon for more details."}
|
||
</p>
|
||
)}
|
||
|
||
<div
|
||
className={`flex flex-wrap justify-center items-center ${
|
||
compact ? "gap-2" : "gap-3"
|
||
} ${links.length === 0 ? "mt-4" : ""}`}
|
||
>
|
||
{links.map(item => (
|
||
<a
|
||
key={item.label}
|
||
href={item.url}
|
||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||
}`}
|
||
style={{ border: `1px solid ${color}` }}
|
||
>
|
||
{item.label}
|
||
</a>
|
||
))}
|
||
|
||
{/* Only when there's nothing to register for and the
|
||
event hasn't happened — the same condition as before,
|
||
just no longer nested inside that branch. */}
|
||
{igHandle && !past && links.length === 0 && (
|
||
<a
|
||
href={igUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
|
||
compact ? "py-2.5 px-5" : "py-3 px-6"
|
||
}`}
|
||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||
>
|
||
<InstagramIcon id={`ig-${ev.id}`} />
|
||
{igHandle}
|
||
</a>
|
||
)}
|
||
|
||
{/* Last, so Register reads first when there is one. */}
|
||
{linked && (
|
||
<Link
|
||
to={eventHref(ev.id)}
|
||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||
}`}
|
||
style={{ border: `1px solid ${color}` }}
|
||
>
|
||
Event details
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
TYPE FILTER
|
||
|
||
Drawn only when a band actually holds more than one kind, so it
|
||
costs nothing today — every event is a retreat — and appears on
|
||
its own the first time a class or a workshop lands in that band.
|
||
Nothing on Retreats.tsx has to be reconfigured for it.
|
||
|
||
Chips rather than a select: with four or five options, all of
|
||
them visible is one tap, and the row reads as what the section
|
||
contains rather than as a form control.
|
||
═══════════════════════════════════════════════════════════════ */
|
||
type TypeFilterValue = EventType | "all";
|
||
|
||
type TypeFilterProps = {
|
||
types: typeof EVENT_TYPES;
|
||
active: TypeFilterValue;
|
||
setActive: (id: TypeFilterValue) => void;
|
||
accent: string;
|
||
};
|
||
|
||
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
|
||
const chip = (on: boolean) => ({
|
||
border: `1px solid ${accent}`,
|
||
background: on ? accent : "transparent",
|
||
color: on ? "#ffffff" : accent,
|
||
});
|
||
|
||
const button = (id: TypeFilterValue, label: string) => (
|
||
<button
|
||
key={id}
|
||
onClick={() => setActive(id)}
|
||
aria-pressed={active === id}
|
||
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
|
||
style={chip(active === id)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
|
||
style={{ maxWidth: GRID_MAX }}
|
||
role="group"
|
||
aria-label="Filter events by type"
|
||
>
|
||
{button("all", "All")}
|
||
{types.map(entry => button(entry.id, entry.plural))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
TOGGLE — the control for the section heading's action bar
|
||
═══════════════════════════════════════════════════════════════ */
|
||
export function EventCardsToggle({
|
||
view,
|
||
setView,
|
||
accent,
|
||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
||
const btn = (active: boolean) => ({
|
||
background: active ? accent : "transparent",
|
||
color: active ? "#ffffff" : accent,
|
||
});
|
||
|
||
return (
|
||
<div
|
||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||
style={{ border: `1px solid ${accent}` }}
|
||
role="group"
|
||
aria-label="Change how events are displayed"
|
||
>
|
||
<button
|
||
onClick={() => setView("carousel")}
|
||
aria-pressed={view === "carousel"}
|
||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||
style={btn(view === "carousel")}
|
||
>
|
||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="7" y="5" width="10" height="14" rx="2" />
|
||
<path d="M3.5 8v8M20.5 8v8" />
|
||
</svg>
|
||
Carousel
|
||
</button>
|
||
<button
|
||
onClick={() => setView("grid")}
|
||
aria-pressed={view === "grid"}
|
||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||
style={btn(view === "grid")}
|
||
>
|
||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||
</svg>
|
||
Grid
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
SECTION
|
||
Fetches its own slice of the events, then draws it as a carousel
|
||
or a grid.
|
||
|
||
An empty list means three different things now — still loading,
|
||
failed, or genuinely nothing scheduled — and they read very
|
||
differently to someone waiting, so they're distinguished rather
|
||
than all falling through to "coming soon".
|
||
═══════════════════════════════════════════════════════════════ */
|
||
type EventListCardsProps = EventFilter & {
|
||
/** "carousel" or "grid". */
|
||
view?: string;
|
||
accent?: string;
|
||
defaultColor?: string;
|
||
empty?: string;
|
||
};
|
||
|
||
export default function EventListCards({
|
||
section,
|
||
host,
|
||
status,
|
||
type,
|
||
view = "carousel",
|
||
accent = TEAL,
|
||
defaultColor,
|
||
empty = "· Events coming soon, stay connected for announcements ·",
|
||
}: EventListCardsProps) {
|
||
const { events: fetched, loading, error } = useEvents({
|
||
section,
|
||
host,
|
||
status,
|
||
type,
|
||
});
|
||
const cardColor = defaultColor ?? accent;
|
||
|
||
const [index, setIndex] = useState(0);
|
||
const [showPast, setShowPast] = useState(false);
|
||
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
|
||
|
||
/* What this band holds, which is what the chips offer — not the
|
||
full list of declared types, three quarters of which would be
|
||
dead buttons. */
|
||
const availableTypes = useMemo(
|
||
() => typesPresent(fetched, EVENT_TYPES),
|
||
[fetched],
|
||
);
|
||
|
||
const mixed = availableTypes.length > 1;
|
||
|
||
const events = useMemo(
|
||
() =>
|
||
activeType === "all"
|
||
? fetched
|
||
: fetched.filter(e => e.event_type === activeType),
|
||
[fetched, activeType],
|
||
);
|
||
|
||
/* Open on the first upcoming event. The list is empty on the
|
||
first render, so this can't be a useState initialiser — it has
|
||
to wait for the data and then run once. Clearing the guard when
|
||
the list empties means a refetch re-seeds. */
|
||
const seeded = useRef(false);
|
||
|
||
/* Changing the chip is a different list, so the carousel re-seeds
|
||
on the first upcoming event of that kind rather than holding an
|
||
index that may now be past the end. Declared before the seed
|
||
effect so the guard is already clear when it runs. */
|
||
useEffect(() => {
|
||
seeded.current = false;
|
||
setIndex(0);
|
||
}, [activeType]);
|
||
|
||
useEffect(() => {
|
||
if (events.length === 0) {
|
||
seeded.current = false;
|
||
return;
|
||
}
|
||
if (seeded.current) return;
|
||
seeded.current = true;
|
||
|
||
const first = events.findIndex(e => e.status === "upcoming");
|
||
setIndex(first === -1 ? events.length - 1 : first);
|
||
}, [events]);
|
||
|
||
// Grid view splits the list; the carousel still shows everything.
|
||
const { upcoming, past: pastEvents } = splitByStatus(events);
|
||
|
||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||
|
||
// Arrows and dots follow the section accent, not the active card.
|
||
const arrowStyle = (enabled: boolean) => ({
|
||
border: `1px solid ${accent}`,
|
||
background: "rgba(255,255,255,0.85)",
|
||
color: enabled ? accent : "#b8c6c9",
|
||
cursor: enabled ? "pointer" : "default",
|
||
opacity: enabled ? 1 : 0.4,
|
||
});
|
||
|
||
const notice = (text: string) => (
|
||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||
{text}
|
||
</p>
|
||
);
|
||
|
||
if (loading && events.length === 0) {
|
||
return (
|
||
<div className="overflow-hidden">
|
||
<div
|
||
className="mx-auto px-8 md:px-12 lg:px-16"
|
||
style={{ maxWidth: GRID_MAX }}
|
||
>
|
||
<div className="skeleton" role="status" aria-label="Loading events" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error && events.length === 0) {
|
||
return (
|
||
<div className="overflow-hidden">
|
||
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-hidden">
|
||
{mixed && (
|
||
<TypeFilter
|
||
types={availableTypes}
|
||
active={activeType}
|
||
setActive={setActiveType}
|
||
accent={accent}
|
||
/>
|
||
)}
|
||
|
||
{events.length === 0 ? (
|
||
/* Two different empties. Nothing scheduled is news; nothing
|
||
of the kind you just picked is a filter you can undo, and
|
||
the chips are still on screen to undo it with. */
|
||
notice(
|
||
activeType === "all"
|
||
? empty
|
||
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
|
||
)
|
||
) : view === "grid" ? (
|
||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||
{upcoming.length > 0 && (
|
||
<div
|
||
className="grid gap-6 items-stretch"
|
||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||
>
|
||
{upcoming.map(ev => (
|
||
<Card
|
||
key={ev.id}
|
||
ev={ev}
|
||
defaultColor={cardColor}
|
||
accent={accent}
|
||
compact
|
||
showType={mixed}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{pastEvents.length > 0 && (
|
||
<div className="mt-12">
|
||
<h3
|
||
className="text-2xl font-800 mb-6"
|
||
style={{ color: accent, opacity: 0.85 }}
|
||
>
|
||
Past Events
|
||
</h3>
|
||
|
||
{/* Collapsed: clipped to PAST_PEEK and faded out at the
|
||
bottom. Expanded: full height, no mask. */}
|
||
<div
|
||
className="relative transition-all duration-500 ease-out"
|
||
style={{
|
||
maxHeight: showPast ? "none" : PAST_PEEK,
|
||
overflow: showPast ? "visible" : "hidden",
|
||
maskImage: showPast ? "none" : PAST_FADE,
|
||
WebkitMaskImage: showPast ? "none" : PAST_FADE,
|
||
}}
|
||
>
|
||
<div
|
||
className="grid gap-6 items-stretch"
|
||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||
aria-hidden={!showPast}
|
||
>
|
||
{pastEvents.map(ev => (
|
||
<Card
|
||
key={ev.id}
|
||
ev={ev}
|
||
defaultColor={cardColor}
|
||
accent={accent}
|
||
compact
|
||
interactive={showPast}
|
||
showType={mixed}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-center mt-6">
|
||
<button
|
||
onClick={() => setShowPast(v => !v)}
|
||
aria-expanded={showPast}
|
||
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
|
||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||
>
|
||
{showPast
|
||
? "Hide past events"
|
||
: `See past events (${pastEvents.length})`}
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
className="h-4 w-4 transition-transform duration-300"
|
||
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M6 9l6 6-6 6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* ── CAROUSEL VIEW ── */
|
||
<>
|
||
<div className="relative mb-6">
|
||
<div
|
||
className="overflow-hidden"
|
||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
||
>
|
||
<div
|
||
className="flex items-stretch transition-transform duration-500 ease-out"
|
||
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
|
||
>
|
||
{events.map((ev, i) => {
|
||
const active = i === index;
|
||
return (
|
||
<div
|
||
key={ev.id}
|
||
className="shrink-0"
|
||
style={{
|
||
width: SLIDE,
|
||
padding: `0 calc(${GAP} / 2)`,
|
||
cursor: active ? "default" : "pointer",
|
||
}}
|
||
onClick={() => !active && setIndex(i)}
|
||
aria-hidden={!active}
|
||
>
|
||
<Card
|
||
ev={ev}
|
||
defaultColor={cardColor}
|
||
accent={accent}
|
||
interactive={active}
|
||
showType={mixed}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{events.length > 1 && (
|
||
<>
|
||
<button
|
||
onClick={prev}
|
||
disabled={index === 0}
|
||
aria-label="Previous event"
|
||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||
style={arrowStyle(index > 0)}
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
onClick={next}
|
||
disabled={index === events.length - 1}
|
||
aria-label="Next event"
|
||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||
style={arrowStyle(index < events.length - 1)}
|
||
>
|
||
›
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{events.length > 1 && (
|
||
<div className="flex justify-center gap-2">
|
||
{events.map((e, i) => (
|
||
<button
|
||
key={e.id}
|
||
onClick={() => setIndex(i)}
|
||
aria-label={`Go to ${e.title}`}
|
||
className="h-2.5 rounded-full transition-all duration-200"
|
||
style={{
|
||
width: i === index ? "1.5rem" : "0.625rem",
|
||
background: i === index ? accent : "#b8c6c9",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|