v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { splitByStatus, useEvents } from "../../data/eventData.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
<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("../../assets/event-logos/*.svg", {
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
</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 }) {
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
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
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.
═══════════════════════════════════════════════════════════════ */
export function Card({
ev,
@ -126,6 +155,8 @@ export function Card({
accent = TEAL,
compact = false,
interactive = true,
linked = true,
showType = false,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
@ -137,6 +168,19 @@ export function Card({
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null;
/* 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) => (
@ -168,7 +212,12 @@ export function Card({
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
{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>
)}
@ -190,7 +239,12 @@ export function Card({
<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" />
<h3 className="text-4xl font-900">{ev.title}</h3>
{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>
)}
@ -211,10 +265,32 @@ export function Card({
</>
)}
{/* Footer — mt-auto pins it to the bottom so buttons line up
across every card in a grid row. */}
{links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
{/* 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}
@ -227,28 +303,17 @@ export function Card({
{item.label}
</a>
))}
</div>
) : past ? (
<p
className="mt-auto text-center font-600 pt-8"
style={{ color: accent }}
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
{/* 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 mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
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 }}
>
@ -256,13 +321,70 @@ export function Card({
{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.
═══════════════════════════════════════════════════════════════ */
export function TypeFilter({ types, active, setActive, accent }) {
const chip = on => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id, label) => (
<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
═══════════════════════════════════════════════════════════════ */
@ -323,22 +445,57 @@ export default function EventListCards({
section,
host,
status,
type,
view = "carousel",
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}) {
const { events, loading, error } = useEvents({ section, host, status });
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("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;
@ -395,8 +552,24 @@ export default function EventListCards({
return (
<div className="overflow-hidden">
{mixed && (
<TypeFilter
types={availableTypes}
active={activeType}
setActive={setActiveType}
accent={accent}
/>
)}
{events.length === 0 ? (
notice(empty)
/* 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 }}>
@ -412,6 +585,7 @@ export default function EventListCards({
defaultColor={cardColor}
accent={accent}
compact
showType={mixed}
/>
))}
</div>
@ -450,6 +624,7 @@ export default function EventListCards({
accent={accent}
compact
interactive={showPast}
showType={mixed}
/>
))}
</div>
@ -514,6 +689,7 @@ export default function EventListCards({
defaultColor={cardColor}
accent={accent}
interactive={active}
showType={mixed}
/>
</div>
);