v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
568
src/pages/sections/EventList-Cards.tsx
Normal file
568
src/pages/sections/EventList-Cards.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../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 => (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 }) {
|
||||
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>
|
||||
);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
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.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
defaultColor = TEAL,
|
||||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
}) {
|
||||
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(/^@/, "")}`
|
||||
: 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">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</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" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</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 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"}`}>
|
||||
{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>
|
||||
))}
|
||||
</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 && (
|
||||
<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"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
<InstagramIcon id={`ig-${ev.id}`} />
|
||||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
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".
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export default function EventListCards({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
|
||||
/* 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);
|
||||
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 => ({
|
||||
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 => (
|
||||
<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">
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
) : 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
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
494
src/pages/sections/FeedbackForm.tsx
Normal file
494
src/pages/sections/FeedbackForm.tsx
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
WEBSITE FEEDBACK FORM
|
||||
|
||||
Posts to /api/feedback, which is the only public write on the
|
||||
site. The server is the authority on validation; the checks here
|
||||
exist to stop someone hitting a 422 they could have avoided, and
|
||||
the two sets are kept deliberately in step (MIN_MESSAGE, and the
|
||||
ids in FEEDBACK_TYPES).
|
||||
|
||||
The page and section options come from navConfig, so adding a
|
||||
page to the nav adds it to this form too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const MUTED = "#4a6b72";
|
||||
const MAX_CHARS = 1500;
|
||||
const MIN_MESSAGE = 10; // matches the server
|
||||
|
||||
// Sentinel for "this isn't about one particular page".
|
||||
const SITE_WIDE = "site";
|
||||
// Sentinel for "this page, but not one section of it".
|
||||
const WHOLE_PAGE = "";
|
||||
|
||||
// Ids must match TYPES in server/src/routes/feedback.js.
|
||||
const FEEDBACK_TYPES = [
|
||||
{
|
||||
id: "broken",
|
||||
label: "Something's broken",
|
||||
hint: "A link, image, or button that doesn't work",
|
||||
},
|
||||
{
|
||||
id: "confusing",
|
||||
label: "Hard to use",
|
||||
hint: "Something you couldn't find or follow",
|
||||
},
|
||||
{
|
||||
id: "outdated",
|
||||
label: "Wrong or missing info",
|
||||
hint: "Old dates, typos, an event that isn't listed",
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
label: "Feature request",
|
||||
hint: "Something you'd like the site to do",
|
||||
},
|
||||
{
|
||||
id: "praise",
|
||||
label: "Kind words",
|
||||
hint: "Tell us what's working well",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
hint: "Anything that doesn't fit the boxes above",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Shared field chrome ─────────────────────────────────────── */
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||
"placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
|
||||
"focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
const errorFieldClass = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||
|
||||
const selectClass = `${fieldClass} appearance-none pr-10`;
|
||||
|
||||
function OptionalTag() {
|
||||
return (
|
||||
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
Optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Native select plus a chevron, since appearance-none strips the default one.
|
||||
function Select({ id, label, value, onChange, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-2">
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
<path
|
||||
d="M5 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
What kind of feedback is this?
|
||||
</legend>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
Pick the closest fit. It helps us route it to the right person.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEEDBACK_TYPES.map((type) => {
|
||||
const selected = value === type.id;
|
||||
return (
|
||||
<label key={type.id} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feedbackType"
|
||||
value={type.id}
|
||||
checked={selected}
|
||||
onChange={() => onChange(type.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#eef9fb]"
|
||||
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="font-semibold text-[#26454c]">{type.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
|
||||
(selected
|
||||
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
|
||||
: "border-[#4a6b72]/30")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{type.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Where on the site ───────────────────────────────────────── */
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">
|
||||
Where did you run into it?
|
||||
</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave this on "Not page-specific" if it applies to the whole site.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
id="feedback-page"
|
||||
label="Page"
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
onPageChange(next);
|
||||
onSectionChange(WHOLE_PAGE); // sections belong to a page
|
||||
}}
|
||||
>
|
||||
<option value={SITE_WIDE}>Not page-specific</option>
|
||||
{PAGE_LINKS.map((p) => (
|
||||
<option key={p.path} value={p.path}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<Select
|
||||
id="feedback-section"
|
||||
label="Section of that page"
|
||||
value={section}
|
||||
onChange={onSectionChange}
|
||||
>
|
||||
<option value={WHOLE_PAGE}>The page as a whole</option>
|
||||
{sections.map((s) => (
|
||||
<option key={s.hash} value={s.hash}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable version of the picked location, for the thank-you panel.
|
||||
function describeLocation(page, section) {
|
||||
if (page === SITE_WIDE) return null;
|
||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||
(s) => s.hash === section,
|
||||
)?.label;
|
||||
return sectionLabel ? `${pageLabel} → ${sectionLabel}` : pageLabel;
|
||||
}
|
||||
|
||||
/* ── The form ────────────────────────────────────────────────── */
|
||||
|
||||
export default function FeedbackForm() {
|
||||
const [type, setType] = useState(null);
|
||||
const [page, setPage] = useState(SITE_WIDE);
|
||||
const [section, setSection] = useState(WHOLE_PAGE);
|
||||
const [message, setMessage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
// Honeypot: never shown, never filled by a person.
|
||||
const [website, setWebsite] = useState("");
|
||||
|
||||
// idle → sending → sent, or back to idle with an error to show.
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
|
||||
const sending = status === "sending";
|
||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!ready || sending) return;
|
||||
|
||||
setStatus("sending");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
await post("/feedback", {
|
||||
feedbackType: type,
|
||||
message: message.trim(),
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
pagePath: page === SITE_WIDE ? "" : page,
|
||||
sectionId: section, // server strips the leading '#'
|
||||
website,
|
||||
});
|
||||
setStatus("sent");
|
||||
} catch (error) {
|
||||
setStatus("idle");
|
||||
if (error instanceof ApiError) {
|
||||
setFieldErrors(error.fields ?? {});
|
||||
setFormError(
|
||||
error.fields
|
||||
? "Have another look at the highlighted fields."
|
||||
: error.message,
|
||||
);
|
||||
} else {
|
||||
// Network failure, offline, server down.
|
||||
setFormError("Couldn't reach the server. Try again in a moment.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setType(null);
|
||||
setPage(SITE_WIDE);
|
||||
setSection(WHOLE_PAGE);
|
||||
setMessage("");
|
||||
setName("");
|
||||
setEmail("");
|
||||
setWebsite("");
|
||||
setStatus("idle");
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
if (status === "sent") {
|
||||
const where = describeLocation(page, section);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
|
||||
Thanks — we've got it
|
||||
</h3>
|
||||
{where && (
|
||||
<p className="mt-3 text-sm" style={{ color: MUTED }}>
|
||||
Filed against {where}.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
|
||||
{email
|
||||
? `We'll follow up at ${email} if we have questions.`
|
||||
: "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<TypePicker value={type} onChange={setType} />
|
||||
|
||||
<div className="mt-10">
|
||||
<LocationPicker
|
||||
page={page}
|
||||
section={section}
|
||||
onPageChange={setPage}
|
||||
onSectionChange={setSection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-10">
|
||||
<label
|
||||
htmlFor="feedback-message"
|
||||
className="text-base font-semibold text-[#26454c]"
|
||||
>
|
||||
Tell us more
|
||||
</label>
|
||||
<p className="mt-1 text-sm" style={{ color: MUTED }}>
|
||||
What you expected to happen, and what happened instead, is the most
|
||||
useful thing you can give us.
|
||||
</p>
|
||||
<textarea
|
||||
id="feedback-message"
|
||||
rows={7}
|
||||
maxLength={MAX_CHARS}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I was looking for the summer retreat dates and…"
|
||||
aria-invalid={Boolean(fieldErrors.message)}
|
||||
aria-describedby={fieldErrors.message ? "feedback-message-error" : undefined}
|
||||
className={`mt-4 resize-y ${fieldClass} ${
|
||||
fieldErrors.message ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<FieldError id="feedback-message-error">{fieldErrors.message}</FieldError>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-xs tabular-nums"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
{message.length} / {MAX_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional contact details */}
|
||||
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">Your details</h3>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
|
||||
Leave these blank and your feedback comes through anonymously. Fill
|
||||
them in only if you'd like a reply — we won't add you to any list.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-name"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedback-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="feedback-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={Boolean(fieldErrors.email)}
|
||||
aria-describedby={fieldErrors.email ? "feedback-email-error" : undefined}
|
||||
className={`mt-2 ${fieldClass} ${
|
||||
fieldErrors.email ? errorFieldClass : ""
|
||||
}`}
|
||||
/>
|
||||
<FieldError id="feedback-email-error">{fieldErrors.email}</FieldError>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honeypot. Off-screen rather than display:none, since bots
|
||||
skip hidden inputs. Never announced, never tabbable. */}
|
||||
<div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden">
|
||||
<label htmlFor="feedback-website">Website</label>
|
||||
<input
|
||||
id="feedback-website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
{formError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-8 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || sending}
|
||||
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
|
||||
>
|
||||
{sending ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
{!ready && !sending && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
Choose a type and write at least a sentence to send.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
240
src/pages/sections/OrgList-Card.tsx
Normal file
240
src/pages/sections/OrgList-Card.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — CARDS
|
||||
|
||||
A grid of organization cards. No toggle: this view is the grid,
|
||||
which is why the section that uses it declares no `views` in the
|
||||
page manifest and gets no control in its heading.
|
||||
|
||||
<OrgListCards kind="partner" pageLabel="Partner page" />
|
||||
|
||||
Where the vertical list expands in place, a card links out. The
|
||||
card carries as much as fits at a glance and the organization's
|
||||
own page carries the rest, so nothing here reads a `blocks`
|
||||
array — only the short card description.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* Wide enough for a logo beside two lines of text, narrow enough
|
||||
that three fit on a laptop. The grid drops a column rather than
|
||||
squeezing below this. */
|
||||
const CARD_MIN = "20rem";
|
||||
const GRID_MAX = "88rem";
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-14 w-14 object-contain rounded-xl shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-14 w-14 rounded-xl shrink-0 flex items-center justify-center font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||
const color = org.color || accent;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Tagline first, then where they are. Partners often have one and
|
||||
// not the other, so this is the line that's most likely to say
|
||||
// something useful.
|
||||
const subtitle = org.tagline || org.location_label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 h-full flex flex-col transition-transform duration-200 hover:scale-[1.01]"
|
||||
style={{ border: `1px solid ${color}`, background: "#ffffff" }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-800 leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm mt-0.5" style={{ color: MUTED }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right rather than in the footer: the footer holds
|
||||
outbound links, and the arrow goes somewhere different
|
||||
in kind — deeper into this site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{org.description?.length > 0 && (
|
||||
<p
|
||||
className="mt-4 text-sm leading-relaxed line-clamp-4"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{org.description[0]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the footer so buttons line up across a row
|
||||
however much text each card carries. */}
|
||||
<div className="mt-auto pt-5 flex flex-wrap gap-2">
|
||||
{org.website && (
|
||||
<a
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{org.email && (
|
||||
<a
|
||||
href={`mailto:${org.email}`}
|
||||
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-12">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-5" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-5 items-stretch"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`,
|
||||
}}
|
||||
>
|
||||
{orgs.map(org => (
|
||||
<OrgCard
|
||||
key={org.id}
|
||||
org={org}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListCards({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Learn more",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided grid.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block
|
||||
title={title}
|
||||
orgs={sorted}
|
||||
accent={accent}
|
||||
pageLabel={pageLabel}
|
||||
siteLabel={siteLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
accent={accent}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
907
src/pages/sections/OrgList-Map.tsx
Normal file
907
src/pages/sections/OrgList-Map.tsx
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommunity } from "../../data/chapters.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — MAP
|
||||
|
||||
Organizations placed on a tile grid: the map on the left, the
|
||||
list on the right, selecting on either side filtering both.
|
||||
|
||||
Filtered to chapters, and unlike the other sections that isn't
|
||||
a prop. A map only makes sense for organizations that sit
|
||||
somewhere, and it reads regions off the same data to colour the
|
||||
tiles — so "plot partners instead" isn't a filter change, it's a
|
||||
different component. Named for the view rather than the filter
|
||||
so that stays visible.
|
||||
|
||||
Why a tile grid rather than a geographic map: every state reads
|
||||
at the same size (so Rhode Island is as clickable as Texas), it
|
||||
stays legible on a phone, it needs no map library or GeoJSON,
|
||||
and a state split between two regions is just a tile painted in
|
||||
two colors. If you later want true geography, the swap point is
|
||||
<RegionMap> — everything else works off the data.
|
||||
|
||||
The tile layout comes from mapGrid.js; who paints what comes
|
||||
from the API. A state and the Canada band are the same shape
|
||||
now, a tile with a span, so the map is one loop.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const TILE = 100;
|
||||
const PAD = 5;
|
||||
const COLS = 13;
|
||||
const ROWS = 7;
|
||||
|
||||
// How wide the map + list block runs. The section heading above it
|
||||
// stays at max-w-6xl, so this deliberately breaks out past it.
|
||||
const CONTENT_MAX = "88rem";
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
const US_TITLE = "US Unity Regions";
|
||||
const INTL_TITLE = "International Unity Regions";
|
||||
|
||||
/* ── Body content ──────────────────────────────────────────────
|
||||
A chapter's description is ordered blocks rather than one
|
||||
string, so a heading or a list added later renders instead of
|
||||
vanishing. When organization and person pages arrive this should
|
||||
move to a shared component; small enough to live here until then.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Blocks({ blocks = [], color }) {
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3" style={{ color: INK }}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return (
|
||||
<h4 key={i} className="text-lg font-800" style={{ color }}>
|
||||
{block.text}
|
||||
</h4>
|
||||
);
|
||||
case "subheading":
|
||||
return (
|
||||
<h5 key={i} className="font-700">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="pl-3 italic"
|
||||
style={{ borderLeft: `2px solid ${color}` }}
|
||||
>
|
||||
{block.text}
|
||||
</blockquote>
|
||||
);
|
||||
case "list":
|
||||
case "links":
|
||||
return (
|
||||
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
||||
{block.items.map((item, j) => (
|
||||
<li key={j}>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color }}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
{item.detail && (
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{" "}
|
||||
— {item.detail}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "divider":
|
||||
return <hr key={i} style={{ borderColor: RULE }} />;
|
||||
default:
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{block.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tile ──────────────────────────────────────────────────────
|
||||
Every region painting this tile gets its own rect. A whole tile
|
||||
is one slice with no edge; a shared tile is two, each declaring
|
||||
which edge it sits on and how much it takes. Nothing here
|
||||
subtracts, and the order the slices arrive in doesn't change
|
||||
what's drawn.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Tile({
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
width = size,
|
||||
label,
|
||||
slices = [],
|
||||
count = 0,
|
||||
selected,
|
||||
hovered,
|
||||
setSelected,
|
||||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}) {
|
||||
if (slices.length === 0) return null;
|
||||
|
||||
const primary = slices[0];
|
||||
const ids = slices.map(s => s.regionId);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
const opacity = count ? 1 : active ? 0.5 : 0.28;
|
||||
|
||||
// Clicking a shared tile cycles through its regions and then
|
||||
// clears, so every slice is reachable without a second control.
|
||||
const cycle = () => {
|
||||
onPick?.(code);
|
||||
const at = ids.indexOf(selected);
|
||||
setSelected(at === ids.length - 1 ? null : ids[at + 1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={cycle}
|
||||
onMouseEnter={() => setHovered(primary.regionId)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
<title>
|
||||
{AREA_NAMES[code] || code} — {slices.map(s => s.name).join(" / ")}
|
||||
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
|
||||
</title>
|
||||
|
||||
<clipPath id={clipId}>
|
||||
<rect x={x} y={y} width={width} height={size} rx={14} />
|
||||
</clipPath>
|
||||
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{slices.map(slice => {
|
||||
const h = slice.edge ? size * slice.share : size;
|
||||
const top = slice.edge === "bottom" ? y + size - h : y;
|
||||
return (
|
||||
<rect
|
||||
key={slice.regionId}
|
||||
x={x}
|
||||
y={top}
|
||||
width={width}
|
||||
height={h}
|
||||
fill={slice.color}
|
||||
fillOpacity={opacity}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + size / 2 + 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
</text>
|
||||
|
||||
{count > 0 && (
|
||||
<circle
|
||||
cx={x + width - 14}
|
||||
cy={y + 14}
|
||||
r={7}
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.9}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||
const size = TILE - PAD * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
|
||||
className="w-full h-auto"
|
||||
role="group"
|
||||
aria-label="Chapters by Unity region"
|
||||
>
|
||||
{/* States and bands are the same shape — a tile with a span —
|
||||
so this is one loop rather than two. */}
|
||||
{AREAS.map(area => (
|
||||
<Tile
|
||||
key={area.code}
|
||||
code={area.code}
|
||||
label={area.isState ? area.code : area.name}
|
||||
x={(area.col - 1) * TILE + PAD}
|
||||
y={(area.row - 1) * TILE + PAD}
|
||||
size={size}
|
||||
width={area.span * TILE - PAD * 2}
|
||||
fontSize={area.isState ? 34 : 38}
|
||||
slices={slices[area.code] ?? []}
|
||||
count={chapterCounts[area.code] ?? 0}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||
const on = selected === region.id || hovered === region.id;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(region.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-pressed={selected === region.id}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${region.color}`,
|
||||
background: on ? region.color : "transparent",
|
||||
color: on ? "#ffffff" : region.color,
|
||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : region.color }}
|
||||
/>
|
||||
{region.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||
const { selected, setSelected } = props;
|
||||
|
||||
// A region is on the map if it paints a tile. West Central used
|
||||
// to need a hardcoded exception here because its states arrived
|
||||
// only through SPLITS; it has ordinary rows now, so the exception
|
||||
// is gone.
|
||||
const onMap = region => onMapIds.has(region.id);
|
||||
const us = domestic.filter(onMap);
|
||||
const intl = international.filter(onMap);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{US_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{us.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px my-4" style={{ background: RULE }} />
|
||||
|
||||
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
|
||||
{INTL_TITLE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{intl.map(r => (
|
||||
<LegendButton key={r.id} region={r} {...props} />
|
||||
))}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="py-1.5 px-3 rounded-lg text-sm font-700 underline"
|
||||
style={{ color: FALLBACK_COLOR }}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionBlock({
|
||||
region,
|
||||
chapters,
|
||||
subtext,
|
||||
selected,
|
||||
setSelected,
|
||||
setHovered,
|
||||
indent,
|
||||
regionRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
const on = selected === region.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(on ? null : region.id)}
|
||||
onMouseEnter={() => setHovered(region.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full flex items-baseline gap-2 text-left py-2"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
{region.name}
|
||||
</h4>
|
||||
<span className="text-sm ml-auto" style={{ color: MUTED }}>
|
||||
{chapters.length || "—"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{subtext && (
|
||||
<p className="text-sm mb-2 ml-5 leading-snug" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm ml-5 mb-4" style={{ color: MUTED }}>
|
||||
No chapters yet — interested in starting one?
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
className="pl-3 flex items-start gap-3"
|
||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700">{c.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{c.location_label}
|
||||
{c.meets ? ` · ${c.meets}` : ""}
|
||||
</p>
|
||||
{(c.website || c.email) && (
|
||||
<p className="text-sm mt-1 flex gap-4">
|
||||
{c.website && (
|
||||
<a
|
||||
href={c.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
{c.email && (
|
||||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orgPath(c) && (
|
||||
<ArrowLink
|
||||
to={orgPath(c)}
|
||||
label={`${c.name} — chapter page`}
|
||||
color={region.color}
|
||||
size="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
GRID VIEW
|
||||
One grid per region. Clicking a card's arrow opens a detail
|
||||
panel directly above that region's grid, framed in the region
|
||||
color. Selecting another card swaps the panel's contents.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Logo, or the organization's initials when there's no file. */
|
||||
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt={org.name}
|
||||
onError={() => setFailed(true)}
|
||||
className={`${size} object-contain rounded-xl shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-label={org.name}
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: open ? `${color}14` : "transparent",
|
||||
}}
|
||||
>
|
||||
<OrgLogo org={chapter} color={color} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700 leading-tight">{chapter.name}</p>
|
||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||
{chapter.location_label}
|
||||
</p>
|
||||
{chapter.meets && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{chapter.meets}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two controls, two destinations: the chevron opens the
|
||||
detail panel in place, the arrow leaves for the chapter's
|
||||
own page. Keeping them distinct beats one control that
|
||||
does different things depending on where you click. */}
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
|
||||
className="h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{orgPath(chapter) && (
|
||||
<ArrowLink
|
||||
to={orgPath(chapter)}
|
||||
label={`${chapter.name} — chapter page`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({ chapter, region, onClose }) {
|
||||
/* "Led by" comes from affiliations rather than a text field, so
|
||||
it lists real people and stays empty until they exist. */
|
||||
const leads = (chapter.leadership ?? [])
|
||||
.map(person =>
|
||||
person.title ? `${person.display_name} (${person.title})` : person.display_name
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const rows = [
|
||||
["Region", region.name],
|
||||
["Where", chapter.venue],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", leads],
|
||||
["Since", chapter.started],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
<p style={{ color: FALLBACK_COLOR }}>{chapter.location_label}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2">
|
||||
<dt className="shrink-0" style={{ color: MUTED }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-700" style={{ color: INK }}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{orgPath(chapter) && (
|
||||
<Link
|
||||
to={orgPath(chapter)}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ background: region.color, color: "#ffffff" }}
|
||||
>
|
||||
Chapter page
|
||||
</Link>
|
||||
)}
|
||||
{chapter.website && (
|
||||
<a
|
||||
href={chapter.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Visit site
|
||||
</a>
|
||||
)}
|
||||
|
||||
{chapter.email && (
|
||||
<a
|
||||
href={`mailto:${chapter.email}`}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${region.color}`, color: region.color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = regions
|
||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||
.filter(({ list }) => list.length > 0);
|
||||
const empty = regions.filter(region => chaptersIn(region.id).length === 0);
|
||||
const openChapter = chapters.find(c => c.id === openId) || null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{populated.map(({ region, list }) => {
|
||||
const subtext = subtextFor(region);
|
||||
return (
|
||||
<section key={region.id} className="mb-12">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</h3>
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
{list.length}
|
||||
</span>
|
||||
</div>
|
||||
{subtext && (
|
||||
<p className="text-sm mb-5 ml-6" style={{ color: MUTED }}>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Detail panel sits above this region's grid, and only
|
||||
when the open chapter belongs to this region. */}
|
||||
{openChapter && openChapter.region_id === region.id && (
|
||||
<ChapterDetail
|
||||
chapter={openChapter}
|
||||
region={region}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-4 items-start"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
|
||||
}}
|
||||
>
|
||||
{list.map(c => (
|
||||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={region.color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{empty.length > 0 && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
No chapters yet in {empty.map(r => r.name).join(", ")} — interested in
|
||||
starting one?
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function OrgMapToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
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 chapters are displayed"
|
||||
>
|
||||
{[
|
||||
["map", "Map"],
|
||||
["grid", "Grid"],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
aria-pressed={view === id}
|
||||
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
regions,
|
||||
regionAreas,
|
||||
domestic,
|
||||
international,
|
||||
virtual,
|
||||
chapters,
|
||||
chaptersIn,
|
||||
slices,
|
||||
chapterCounts,
|
||||
regionsForArea,
|
||||
subtextFor,
|
||||
} = useCommunity();
|
||||
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||
|
||||
// The list scrolls itself to whatever the map or legend points at.
|
||||
const listRef = useRef(null);
|
||||
const regionRefs = useRef({});
|
||||
const chapterRefs = useRef({});
|
||||
|
||||
const scrollListTo = el => {
|
||||
const box = listRef.current;
|
||||
if (!box || !el) return;
|
||||
// Only when the list is its own scroll area (lg and up). Below
|
||||
// that it's stacked under the map and scrolling it would fight
|
||||
// the page.
|
||||
if (box.scrollHeight <= box.clientHeight) return;
|
||||
box.scrollTo({ top: el.offsetTop - 8, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Hovering or selecting a region brings that block into view.
|
||||
useEffect(() => {
|
||||
const id = hovered || selected;
|
||||
if (id) scrollListTo(regionRefs.current[id]);
|
||||
}, [hovered, selected]);
|
||||
|
||||
// Clicking a tile jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickArea = code => {
|
||||
const chapter = chapters.find(c => c.area_code === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
}
|
||||
const region = regionsForArea(code)[0];
|
||||
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||
};
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && regions.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading chapters" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && regions.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· Chapters couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "grid") {
|
||||
return (
|
||||
<ChapterGrid
|
||||
regions={regions}
|
||||
chapters={chapters}
|
||||
chaptersIn={chaptersIn}
|
||||
subtextFor={subtextFor}
|
||||
openId={openId}
|
||||
setOpenId={setOpenId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||
|
||||
const block = (region, indent) => (
|
||||
<RegionBlock
|
||||
key={region.id}
|
||||
region={region}
|
||||
chapters={chaptersIn(region.id)}
|
||||
subtext={subtextFor(region)}
|
||||
indent={indent}
|
||||
regionRefs={regionRefs}
|
||||
chapterRefs={chapterRefs}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
/* Wider than the section heading above it — the map needs the
|
||||
room. CONTENT_MAX is the knob; drop it toward 72rem to pull
|
||||
the whole block back in line with the heading. */
|
||||
shell(
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
|
||||
{/* Map */}
|
||||
<div>
|
||||
<RegionMap
|
||||
slices={slices}
|
||||
chapterCounts={chapterCounts}
|
||||
onPick={pickArea}
|
||||
{...shared}
|
||||
/>
|
||||
<Legend
|
||||
domestic={domestic}
|
||||
international={international}
|
||||
onMapIds={onMapIds}
|
||||
{...shared}
|
||||
/>
|
||||
<p className="text-sm mt-4" style={{ color: MUTED }}>
|
||||
A filled tile means a chapter meets there; a two-tone tile is a
|
||||
state shared by two Unity regions. Select a region to filter the
|
||||
list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* List — h-0 + min-h-full makes this column take its height
|
||||
from the map column rather than the other way round, so a
|
||||
long list scrolls instead of stretching the section. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{US_TITLE}
|
||||
</h3>
|
||||
{domestic.map(r => block(r, true))}
|
||||
|
||||
<h3
|
||||
className="text-xl font-800 mt-6 mb-1 pt-4"
|
||||
style={{ color: FALLBACK_COLOR, borderTop: `1px solid ${RULE}` }}
|
||||
>
|
||||
{INTL_TITLE}
|
||||
</h3>
|
||||
{international.map(r => block(r, true))}
|
||||
|
||||
<div className="mt-6 pt-4" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
{virtual.map(r => block(r, false))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
326
src/pages/sections/OrgList-Vertical.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { useId, useState } from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
orgPath,
|
||||
useOrganizations,
|
||||
} from "../../data/organizations.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — VERTICAL
|
||||
|
||||
A stack of organizations, each collapsed to a line and
|
||||
expandable for the rest. Every row carries two ways out: an
|
||||
arrow through to that organization's page on this site, and a
|
||||
labelled button out to its own site when it has one.
|
||||
|
||||
Nothing here is region-specific. It asks for a kind and renders
|
||||
what comes back, so the same section lists partners or chapters
|
||||
by changing one prop:
|
||||
|
||||
<OrgListVertical kind="partner" title="Our Partners" />
|
||||
|
||||
<OrgListVertical
|
||||
kind="region"
|
||||
groups={[
|
||||
{ key: "domestic", title: "US Unity Regions" },
|
||||
{ key: "international", title: "International Unity Regions" },
|
||||
]}
|
||||
groupBy={org => org.details?.scope}
|
||||
/>
|
||||
|
||||
Kind-specific extras (the areas a region covers, the chapters
|
||||
inside it) come from `details` and render only when present, so
|
||||
a partner row simply doesn't have them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_BASE = "/org-logos/";
|
||||
|
||||
const MUTED = "#7a9299";
|
||||
const RULE = "#cfe3e7";
|
||||
const INK = "#2c4a50";
|
||||
const FALLBACK_COLOR = "#4a6b72";
|
||||
|
||||
/* ── Row button ───────────────────────────────────────────────
|
||||
Deliberately a sibling of the summary button rather than inside
|
||||
it — a link nested in a button is invalid, and a screen reader
|
||||
announces the whole row as one confused control.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||
return (
|
||||
<As
|
||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={`${LOGO_BASE}${org.logo}`}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-8 w-8 object-contain shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (org.color) {
|
||||
return (
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-8 w-8 rounded-lg shrink-0 flex items-center justify-center text-xs font-800"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initialsFor(org.name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
const color = org.color || FALLBACK_COLOR;
|
||||
const path = orgPath(org);
|
||||
|
||||
// Region extras. Absent for every other kind, and the JSX below
|
||||
// skips them rather than rendering empty headings.
|
||||
const areas = areasSentence(org.details?.areas ?? []);
|
||||
const chapterCount = (org.details?.chapters ?? []).length;
|
||||
const note = org.details?.map_note;
|
||||
|
||||
/* Collapsed, a row says who it is and nothing else — the areas
|
||||
sentence runs long and lives in the panel, where it appears
|
||||
exactly once rather than in both places. */
|
||||
const summary = org.tagline;
|
||||
|
||||
// Something has to be behind the chevron or opening it does nothing.
|
||||
const hasPanel =
|
||||
org.description?.length > 0 || areas || note || org.links?.length > 0;
|
||||
|
||||
return (
|
||||
<div className="py-3" style={{ borderTop: `1px solid ${RULE}` }}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className="flex-1 min-w-0 flex items-center gap-3 text-left py-1"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ol-chevron h-4 w-4 shrink-0"
|
||||
style={{ color, transform: open ? "rotate(90deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
|
||||
<OrgMark org={org} color={color} />
|
||||
|
||||
<span className="min-w-0">
|
||||
<span className="block font-800 text-lg leading-tight" style={{ color }}>
|
||||
{org.name}
|
||||
</span>
|
||||
{summary && (
|
||||
<span className="block text-sm leading-snug" style={{ color: MUTED }}>
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{chapterCount > 0 && (
|
||||
<span
|
||||
className="ml-auto text-sm shrink-0"
|
||||
style={{ color: MUTED }}
|
||||
title={`${chapterCount} chapter${chapterCount > 1 ? "s" : ""}`}
|
||||
>
|
||||
{chapterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{org.website && (
|
||||
<RowButton
|
||||
as="a"
|
||||
href={org.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{siteLabel} ↗
|
||||
</RowButton>
|
||||
)}
|
||||
|
||||
{/* Last in the row, so the arrow lines up down the right
|
||||
edge whether or not a row has an outbound site. */}
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={path}
|
||||
label={`${org.name} — ${pageLabel}`}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 0fr → 1fr animates to the panel's real height, so nobody
|
||||
has to guess a max-height that's wrong the moment a
|
||||
description grows. */}
|
||||
<div
|
||||
id={panelId}
|
||||
className="ol-panel"
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pt-2 pb-3 pl-10 pr-2 flex flex-col gap-3">
|
||||
{org.description?.map((text, i) => (
|
||||
<p key={i} className="leading-relaxed" style={{ color: INK }}>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{areas && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
<span className="font-700">Covers</span> {areas}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{note && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasPanel && (
|
||||
<p className="text-sm" style={{ color: MUTED }}>
|
||||
More about this region soon.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{org.links?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{org.links.map(link => (
|
||||
<RowButton
|
||||
key={link.url}
|
||||
as="a"
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color={color}
|
||||
>
|
||||
{link.label}
|
||||
</RowButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
{title && (
|
||||
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{orgs.map(org => (
|
||||
<OrgRow key={org.id} org={org} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
|
||||
export default function OrgListVertical({
|
||||
kind,
|
||||
title,
|
||||
groups,
|
||||
groupBy,
|
||||
accent = FALLBACK_COLOR,
|
||||
pageLabel = "Region page",
|
||||
siteLabel = "Visit site",
|
||||
sort = byName,
|
||||
empty = "· Nothing to show here just yet ·",
|
||||
}) {
|
||||
const { organizations, loading, error } = useOrganizations(kind);
|
||||
|
||||
const shell = children => (
|
||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||
);
|
||||
|
||||
if (loading && organizations.length === 0) {
|
||||
return shell(
|
||||
<div className="skeleton" role="status" aria-label="Loading organizations" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error && organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
· This list couldn't be loaded just now — please try again shortly ·
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return shell(
|
||||
<p className="font-600" style={{ color: accent }}>
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sort ? [...organizations].sort(sort) : organizations;
|
||||
|
||||
// No groups given means one undivided list.
|
||||
if (!groups || !groupBy) {
|
||||
return shell(
|
||||
<Block title={title} orgs={sorted} pageLabel={pageLabel} siteLabel={siteLabel} />
|
||||
);
|
||||
}
|
||||
|
||||
return shell(
|
||||
groups.map(group => (
|
||||
<Block
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
orgs={sorted.filter(org => groupBy(org) === group.key)}
|
||||
pageLabel={group.pageLabel ?? pageLabel}
|
||||
siteLabel={group.siteLabel ?? siteLabel}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue