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
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