Type src/ against API and database shapes, fixing implicit anys

Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-25 04:13:46 -05:00
parent 5286cae9ca
commit 2428f4412a
39 changed files with 1318 additions and 343 deletions

View file

@ -1,8 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
import {
splitByStatus,
typesPresent,
useEvents,
type EventFilter,
} from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
import type { EventListItem } from "../../lib/useContent.ts";
import type { SectionToggleProps } from "../../lib/sections.tsx";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
@ -24,7 +31,7 @@ import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
nothing but retreats shows no control at all.
═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
@ -33,7 +40,7 @@ const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
const logoSrc = file => (file && LOGOS[file]) || null;
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
/* Last resort only. The API already falls back to the host
organization's logo when an event doesn't name its own, so this
@ -41,7 +48,15 @@ const logoSrc = file => (file && LOGOS[file]) || null;
const DEFAULT_ORG_LOGO = null;
/* An <img> that removes itself if the file 404s. */
function Logo({ file, alt = "", className }) {
function Logo({
file,
alt = "",
className,
}: {
file: string | null | undefined;
alt?: string;
className?: string;
}) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
@ -122,7 +137,15 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
carousel has the same constraint — it needs the card's click to
mean "bring this one to the front" on anything that isn't the
active slide. */
function TitleLink({ ev, linked, children }) {
function TitleLink({
ev,
linked,
children,
}: {
ev: Pick<EventListItem, "id">;
linked: boolean;
children: ReactNode;
}) {
if (!linked) return <>{children}</>;
return (
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
@ -149,6 +172,16 @@ function TitleLink({ ev, linked, children }) {
`linked` is the one thing a caller turns off: a card on the
event's own page shouldn't link to the page it's already on.
═══════════════════════════════════════════════════════════════ */
type CardProps = {
ev: EventListItem;
defaultColor?: string;
accent?: string;
compact?: boolean;
interactive?: boolean;
linked?: boolean;
showType?: boolean;
};
export function Card({
ev,
defaultColor = TEAL,
@ -157,7 +190,7 @@ export function Card({
interactive = true,
linked = true,
showType = false,
}) {
}: CardProps) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
@ -353,14 +386,23 @@ export function Card({
them visible is one tap, and the row reads as what the section
contains rather than as a form control.
═══════════════════════════════════════════════════════════════ */
export function TypeFilter({ types, active, setActive, accent }) {
const chip = on => ({
type TypeFilterValue = EventType | "all";
type TypeFilterProps = {
types: typeof EVENT_TYPES;
active: TypeFilterValue;
setActive: (id: TypeFilterValue) => void;
accent: string;
};
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
const chip = (on: boolean) => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id, label) => (
const button = (id: TypeFilterValue, label: string) => (
<button
key={id}
onClick={() => setActive(id)}
@ -388,8 +430,12 @@ export function TypeFilter({ types, active, setActive, accent }) {
/* ═══════════════════════════════════════════════════════════════
TOGGLE — the control for the section heading's action bar
═══════════════════════════════════════════════════════════════ */
export function EventCardsToggle({ view, setView, accent }) {
const btn = active => ({
export function EventCardsToggle({
view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
@ -441,6 +487,14 @@ export function EventCardsToggle({ view, setView, accent }) {
differently to someone waiting, so they're distinguished rather
than all falling through to "coming soon".
═══════════════════════════════════════════════════════════════ */
type EventListCardsProps = EventFilter & {
/** "carousel" or "grid". */
view?: string;
accent?: string;
defaultColor?: string;
empty?: string;
};
export default function EventListCards({
section,
host,
@ -450,7 +504,7 @@ export default function EventListCards({
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}) {
}: EventListCardsProps) {
const { events: fetched, loading, error } = useEvents({
section,
host,
@ -461,7 +515,7 @@ export default function EventListCards({
const [index, setIndex] = useState(0);
const [showPast, setShowPast] = useState(false);
const [activeType, setActiveType] = useState("all");
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
/* What this band holds, which is what the chips offer — not the
full list of declared types, three quarters of which would be
@ -515,7 +569,7 @@ export default function EventListCards({
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 => ({
const arrowStyle = (enabled: boolean) => ({
border: `1px solid ${accent}`,
background: "rgba(255,255,255,0.85)",
color: enabled ? accent : "#b8c6c9",
@ -523,7 +577,7 @@ export default function EventListCards({
opacity: enabled ? 1 : 0.4,
});
const notice = text => (
const notice = (text: string) => (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
{text}
</p>

View file

@ -11,7 +11,7 @@
page to the nav adds it to this form too.
═══════════════════════════════════════════════════════════════ */
import { useState } from "react";
import { useState, type FormEvent, type ReactNode } from "react";
import { post, ApiError } from "../../lib/api.js";
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
@ -46,7 +46,7 @@ function OptionalTag() {
);
}
function FieldError({ id, children }) {
function FieldError({ id, children }: { id: string; children?: ReactNode }) {
if (!children) return null;
return (
<p id={id} className="mt-2 text-sm text-[#b3261e]">
@ -56,7 +56,15 @@ function FieldError({ id, children }) {
}
// Native select plus a chevron, since appearance-none strips the default one.
function Select({ id, label, value, onChange, children }) {
type SelectProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
children: ReactNode;
};
function Select({ id, label, value, onChange, children }: SelectProps) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
@ -93,7 +101,13 @@ function Select({ id, label, value, onChange, children }) {
/* ── Type picker ─────────────────────────────────────────────── */
function TypePicker({ value, onChange }) {
function TypePicker({
value,
onChange,
}: {
value: string | null;
onChange: (id: string) => void;
}) {
return (
<fieldset>
<legend className="text-base font-semibold text-[#26454c]">
@ -151,7 +165,16 @@ function TypePicker({ value, onChange }) {
/* ── Where on the site ───────────────────────────────────────── */
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
type LocationPickerProps = {
/** A nav path, or SITE_WIDE. */
page: string;
/** A nav hash, or WHOLE_PAGE. */
section: string;
onPageChange: (page: string) => void;
onSectionChange: (section: string) => void;
};
function LocationPicker({ page, section, onPageChange, onSectionChange }: LocationPickerProps) {
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
return (
@ -205,7 +228,7 @@ function LocationPicker({ page, section, onPageChange, onSectionChange }) {
}
// Human-readable version of the picked location, for the thank-you panel.
function describeLocation(page, section) {
function describeLocation(page: string, section: string) {
if (page === SITE_WIDE) return null;
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
@ -216,8 +239,11 @@ function describeLocation(page, section) {
/* ── The form ────────────────────────────────────────────────── */
/* The fields server/src/routes/feedback.js can reject by name. */
type FeedbackFieldErrors = { message?: string; email?: string };
export default function FeedbackForm() {
const [type, setType] = useState(null);
const [type, setType] = useState<string | null>(null);
const [page, setPage] = useState(SITE_WIDE);
const [section, setSection] = useState(WHOLE_PAGE);
const [message, setMessage] = useState("");
@ -228,14 +254,14 @@ export default function FeedbackForm() {
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 [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FeedbackFieldErrors>({});
const sending = status === "sending";
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
async function handleSubmit(event) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!ready || sending) return;

View file

@ -1,5 +1,7 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import {
initialsFor,
orgPath,
@ -33,7 +35,13 @@ const FALLBACK_COLOR = "#4a6b72";
const CARD_MIN = "20rem";
const GRID_MAX = "88rem";
function OrgMark({ org, color }) {
function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
}) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -58,7 +66,14 @@ function OrgMark({ org, color }) {
);
}
function OrgCard({ org, accent, pageLabel, siteLabel }) {
type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
function OrgCard({
org,
accent,
pageLabel,
siteLabel,
}: CardLabels & { org: OrganizationListItem }) {
const color = org.color || accent;
const path = orgPath(org);
@ -136,7 +151,13 @@ function OrgCard({ org, accent, pageLabel, siteLabel }) {
);
}
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
function Block({
title,
orgs,
accent,
pageLabel,
siteLabel,
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
if (orgs.length === 0) return null;
return (
@ -167,7 +188,30 @@ function Block({ title, orgs, accent, pageLabel, siteLabel }) {
);
}
const byName = (a, b) => a.name.localeCompare(b.name);
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
a.name.localeCompare(b.name);
/* One heading's worth of the grid. Labels override the grid's own. */
type OrgGroup = {
key: string;
title: string;
pageLabel?: string;
siteLabel?: string;
};
type OrgListCardsProps = {
kind?: OrgKind;
title?: string;
groups?: OrgGroup[];
/** Which group key an organization files under. */
groupBy?: (org: OrganizationListItem) => string | null | undefined;
accent?: string;
pageLabel?: string;
siteLabel?: string;
/** Null keeps the API's order. */
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
empty?: string;
};
export default function OrgListCards({
kind,
@ -179,10 +223,10 @@ export default function OrgListCards({
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}) {
}: OrgListCardsProps) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = children => (
const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
{children}
</div>

View file

@ -1,9 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { useCommunity } from "../../data/chapters.js";
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import {
useCommunity,
type Chapter,
type Community,
type Region,
} 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";
import { AREAS, AREA_NAMES, type AreaSlice } from "../../data/mapGrid.js";
import type { SectionToggleProps } from "../../lib/sections.tsx";
import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — MAP
@ -46,6 +53,19 @@ const RULE = "#cfe3e7";
const INK = "#2c4a50";
const FALLBACK_COLOR = "#4a6b72";
/* organizations.color is nullable; an SVG fill left undefined paints
black, so an uncoloured region takes the same fallback as a card. */
const colorOf = (item: { color?: string | null }) => item.color || FALLBACK_COLOR;
/* Which region is picked and which is under the pointer, shared by
the map, the legend and the list. */
type Highlight = {
selected: string | null;
hovered: string | null;
setSelected: (id: string | null) => void;
setHovered: (id: string | null) => void;
};
const US_TITLE = "US Unity Regions";
const INTL_TITLE = "International Unity Regions";
@ -55,7 +75,7 @@ const INTL_TITLE = "International Unity Regions";
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 }) {
function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string }) {
if (blocks.length === 0) return null;
return (
@ -88,7 +108,7 @@ function Blocks({ blocks = [], color }) {
case "links":
return (
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
{block.items.map((item, j) => (
{(block.items ?? []).map((item, j) => (
<li key={j}>
{item.url ? (
<a
@ -134,6 +154,19 @@ function Blocks({ blocks = [], color }) {
subtracts, and the order the slices arrive in doesn't change
what's drawn.
───────────────────────────────────────────────────────────── */
type TileProps = Highlight & {
code: string;
x: number;
y: number;
size: number;
width?: number;
label?: string;
slices?: AreaSlice[];
count?: number;
onPick?: (code: string) => void;
fontSize?: number;
};
function Tile({
code,
x,
@ -149,11 +182,12 @@ function Tile({
setHovered,
onPick,
fontSize = 34,
}) {
}: TileProps) {
if (slices.length === 0) return null;
const primary = slices[0];
const ids = slices.map(s => s.regionId);
// Nullable so indexOf and includes take `selected` as it is.
const ids: Array<string | null> = slices.map(s => s.regionId);
const active = ids.includes(selected) || ids.includes(hovered);
const dimmed = selected && !ids.includes(selected);
const clipId = `clip-${code}`;
@ -196,7 +230,7 @@ function Tile({
y={top}
width={width}
height={h}
fill={slice.color}
fill={colorOf(slice)}
fillOpacity={opacity}
className="transition-all duration-200"
/>
@ -211,7 +245,7 @@ function Tile({
height={size}
rx={14}
fill="none"
stroke={active ? primary.color : "#ffffff"}
stroke={active ? colorOf(primary) : "#ffffff"}
strokeOpacity={active ? 1 : 0.55}
strokeWidth={active ? 4 : 2}
className="transition-all duration-200"
@ -224,7 +258,7 @@ function Tile({
dominantBaseline="middle"
fontSize={fontSize}
fontWeight="800"
fill={count ? "#ffffff" : primary.color}
fill={count ? "#ffffff" : colorOf(primary)}
style={{ pointerEvents: "none" }}
>
{label || code}
@ -244,7 +278,13 @@ function Tile({
);
}
function RegionMap({ slices, chapterCounts, ...props }) {
type RegionMapProps = Highlight & {
slices: Record<string, AreaSlice[]>;
chapterCounts: Record<string, number>;
onPick?: (code: string) => void;
};
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
const size = TILE - PAD * 2;
return (
@ -275,8 +315,15 @@ function RegionMap({ slices, chapterCounts, ...props }) {
);
}
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
function LegendButton({
region,
selected,
setSelected,
hovered,
setHovered,
}: Highlight & { region: Region }) {
const on = selected === region.id || hovered === region.id;
const color = colorOf(region);
return (
<button
onClick={() => setSelected(selected === region.id ? null : region.id)}
@ -287,29 +334,36 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
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,
border: `1px solid ${color}`,
background: on ? color : "transparent",
color: on ? "#ffffff" : 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 }}
style={{ background: on ? "#ffffff" : color }}
/>
{region.name}
</button>
);
}
function Legend({ domestic, international, onMapIds, ...props }) {
type LegendProps = Highlight & {
domestic: Region[];
international: Region[];
/** Regions that paint at least one tile. */
onMapIds: Set<string>;
};
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
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 onMap = (region: Region) => onMapIds.has(region.id);
const us = domestic.filter(onMap);
const intl = international.filter(onMap);
@ -347,6 +401,15 @@ function Legend({ domestic, international, onMapIds, ...props }) {
);
}
type RegionBlockProps = Highlight & {
region: Region;
chapters: Chapter[];
subtext: string;
indent: boolean;
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
};
function RegionBlock({
region,
chapters,
@ -357,12 +420,15 @@ function RegionBlock({
indent,
regionRefs,
chapterRefs,
}) {
}: RegionBlockProps) {
const on = selected === region.id;
const color = colorOf(region);
return (
<div
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
ref={el => {
if (regionRefs) regionRefs.current[region.id] = el;
}}
className="transition-opacity duration-200"
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
>
@ -374,11 +440,11 @@ function RegionBlock({
>
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: region.color }}
style={{ background: color }}
/>
<h4
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
style={{ color: region.color }}
style={{ color: color }}
>
{region.name}
</h4>
@ -399,55 +465,60 @@ function RegionBlock({
</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>
)}
{chapters.map(c => {
const path = orgPath(c);
return (
<li
key={c.id}
ref={el => {
if (chapterRefs) chapterRefs.current[c.id] = el;
}}
className="pl-3 flex items-start gap-3"
style={{ borderLeft: `2px solid ${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>
)}
</div>
{(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: color }}
>
Details
</a>
)}
{c.email && (
<a
href={`mailto:${c.email}`}
className="font-700 underline"
style={{ color: 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>
))}
{path && (
<ArrowLink
to={path}
label={`${c.name} — chapter page`}
color={color}
size="h-8 w-8"
/>
)}
</li>
);
})}
</ul>
)}
</div>
@ -462,7 +533,15 @@ function RegionBlock({
═══════════════════════════════════════════════════════════════ */
/* Logo, or the organization's initials when there's no file. */
function OrgLogo({ org, color, size = "h-14 w-14" }) {
function OrgLogo({
org,
color,
size = "h-14 w-14",
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
size?: string;
}) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -487,7 +566,16 @@ function OrgLogo({ org, color, size = "h-14 w-14" }) {
);
}
function ChapterCard({ chapter, color, open, onOpen }) {
type ChapterCardProps = {
chapter: Chapter;
color: string;
open: boolean;
onOpen: () => void;
};
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
const path = orgPath(chapter);
return (
<div
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
@ -539,9 +627,9 @@ function ChapterCard({ chapter, color, open, onOpen }) {
</svg>
</button>
{orgPath(chapter) && (
{path && (
<ArrowLink
to={orgPath(chapter)}
to={path}
label={`${chapter.name} — chapter page`}
color={color}
/>
@ -551,7 +639,18 @@ function ChapterCard({ chapter, color, open, onOpen }) {
);
}
function ChapterDetail({ chapter, region, onClose }) {
function ChapterDetail({
chapter,
region,
onClose,
}: {
chapter: Chapter;
region: Region;
onClose: () => void;
}) {
const path = orgPath(chapter);
const color = colorOf(region);
/* "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 ?? [])
@ -560,21 +659,23 @@ function ChapterDetail({ chapter, region, onClose }) {
)
.join(", ");
const rows = [
["Region", region.name],
["Where", chapter.venue],
["Meets", chapter.meets],
["Led by", leads],
["Since", chapter.started],
].filter(([, v]) => v);
const rows = (
[
["Region", region.name],
["Where", chapter.venue],
["Meets", chapter.meets],
["Led by", leads],
["Since", chapter.started],
] satisfies Array<[label: string, value: string | null | undefined]>
).filter(([, v]) => v);
return (
<div
className="rounded-2xl p-6 mb-6"
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
style={{ border: `2px solid ${color}`, background: `${color}0f` }}
>
<div className="flex items-start gap-4">
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
<OrgLogo org={chapter} color={color} size="h-20 w-20" />
<div className="min-w-0 flex-1">
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
@ -585,13 +686,13 @@ function ChapterDetail({ chapter, region, onClose }) {
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 }}
style={{ border: `1px solid ${color}`, color: color }}
>
×
</button>
</div>
<Blocks blocks={chapter.blocks} color={region.color} />
<Blocks blocks={chapter.blocks} color={color} />
{rows.length > 0 && (
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
@ -609,11 +710,11 @@ function ChapterDetail({ chapter, region, onClose }) {
)}
<div className="mt-5 flex flex-wrap gap-3">
{orgPath(chapter) && (
{path && (
<Link
to={orgPath(chapter)}
to={path}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ background: region.color, color: "#ffffff" }}
style={{ background: color, color: "#ffffff" }}
>
Chapter page
</Link>
@ -624,7 +725,7 @@ function ChapterDetail({ chapter, region, onClose }) {
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 }}
style={{ border: `1px solid ${color}`, color: color }}
>
Visit site
</a>
@ -634,7 +735,7 @@ function ChapterDetail({ chapter, region, onClose }) {
<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 }}
style={{ border: `1px solid ${color}`, color: color }}
>
Get in touch
</a>
@ -644,7 +745,19 @@ function ChapterDetail({ chapter, region, onClose }) {
);
}
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
type ChapterGridProps = Pick<Community, "regions" | "chapters" | "chaptersIn" | "subtextFor"> & {
openId: string | null;
setOpenId: (id: string | null) => void;
};
function ChapterGrid({
regions,
chapters,
chaptersIn,
subtextFor,
openId,
setOpenId,
}: ChapterGridProps) {
// Only regions that actually have chapters get a grid.
const populated = regions
.map(region => ({ region, list: chaptersIn(region.id) }))
@ -656,14 +769,15 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{populated.map(({ region, list }) => {
const subtext = subtextFor(region);
const color = colorOf(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 }}
style={{ background: color }}
/>
<h3 className="text-2xl font-800" style={{ color: region.color }}>
<h3 className="text-2xl font-800" style={{ color: color }}>
{region.name}
</h3>
<span className="text-sm" style={{ color: MUTED }}>
@ -696,7 +810,7 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
<ChapterCard
key={c.id}
chapter={c}
color={region.color}
color={color}
open={openId === c.id}
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
/>
@ -717,8 +831,12 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
}
/* The control for the section heading's action bar. */
export function OrgMapToggle({ view, setView, accent }) {
const btn = active => ({
export function OrgMapToggle({
view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
@ -748,7 +866,14 @@ export function OrgMapToggle({ view, setView, accent }) {
);
}
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
export default function OrgListMap({
view = "map",
accent = FALLBACK_COLOR,
}: {
/** "map" or "grid". */
view?: string;
accent?: string;
}) {
const {
loading,
error,
@ -765,16 +890,16 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
subtextFor,
} = useCommunity();
const [selected, setSelected] = useState(null);
const [hovered, setHovered] = useState(null);
const [openId, setOpenId] = useState(null); // no card open on arrival
const [selected, setSelected] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(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 listRef = useRef<HTMLDivElement>(null);
const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
const scrollListTo = el => {
const scrollListTo = (el: HTMLElement | null | undefined) => {
const box = listRef.current;
if (!box || !el) return;
// Only when the list is its own scroll area (lg and up). Below
@ -792,7 +917,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
// Clicking a tile jumps to its first chapter when it has one,
// otherwise to the region it belongs to.
const pickArea = code => {
const pickArea = (code: string) => {
const chapter = chapters.find(c => c.area_code === code);
if (chapter && chapterRefs.current[chapter.id]) {
return scrollListTo(chapterRefs.current[chapter.id]);
@ -801,7 +926,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
if (region) scrollListTo(regionRefs.current[region.id]);
};
const shell = children => (
const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{children}
</div>
@ -837,7 +962,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
const shared = { selected, setSelected, hovered, setHovered };
const onMapIds = new Set(regionAreas.map(a => a.region_id));
const block = (region, indent) => (
const block = (region: Region, indent: boolean) => (
<RegionBlock
key={region.id}
region={region}

View file

@ -1,5 +1,13 @@
import { useId, useState } from "react";
import {
useId,
useState,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import {
areasSentence,
initialsFor,
@ -47,7 +55,14 @@ const FALLBACK_COLOR = "#4a6b72";
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 }) {
type RowButtonProps = {
as?: "a" | "button";
color: string;
children: ReactNode;
} & AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonHTMLAttributes<HTMLButtonElement>;
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
return (
<As
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
@ -59,7 +74,13 @@ function RowButton({ as: As = "button", color, children, ...rest }) {
);
}
function OrgMark({ org, color }) {
function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
color: string;
}) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -94,7 +115,9 @@ function OrgMark({ org, color }) {
);
}
function OrgRow({ org, pageLabel, siteLabel }) {
type RowLabels = { pageLabel: string; siteLabel: string };
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
const [open, setOpen] = useState(false);
const panelId = useId();
@ -245,7 +268,12 @@ function OrgRow({ org, pageLabel, siteLabel }) {
);
}
function Block({ title, orgs, pageLabel, siteLabel }) {
function Block({
title,
orgs,
pageLabel,
siteLabel,
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
if (orgs.length === 0) return null;
return (
@ -262,7 +290,30 @@ function Block({ title, orgs, pageLabel, siteLabel }) {
);
}
const byName = (a, b) => a.name.localeCompare(b.name);
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
a.name.localeCompare(b.name);
/* One heading's worth of the list. Labels override the list's own. */
type OrgGroup = {
key: string;
title: string;
pageLabel?: string;
siteLabel?: string;
};
type OrgListVerticalProps = {
kind?: OrgKind;
title?: string;
groups?: OrgGroup[];
/** Which group key an organization files under. */
groupBy?: (org: OrganizationListItem) => string | null | undefined;
accent?: string;
pageLabel?: string;
siteLabel?: string;
/** Null keeps the API's order. */
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
empty?: string;
};
export default function OrgListVertical({
kind,
@ -274,10 +325,10 @@ export default function OrgListVertical({
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}) {
}: OrgListVerticalProps) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = children => (
const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
);