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:
parent
5286cae9ca
commit
2428f4412a
39 changed files with 1318 additions and 343 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue