v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue