NGU-Web/src/components/PeopleTiles.tsx
Zaldimmar 2428f4412a 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>
2026-09-25 04:13:46 -05:00

755 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
} from "react";
import { get } from "../lib/api.js";
import "./PeopleTiles.css";
/**
* PeopleTiles — a horizontal, polaroid-style people list.
*
* Supply data any of four ways:
*
* <PeopleTiles size="lg" teams="ngu-board" />
* <PeopleTiles size="md" teams={["ngu-board", "nw-leadership"]} />
* <PeopleTiles size="sm" people={[
* { peopleslug: "john-doe", title: "Hospitality" },
* { peopleslug: "jane-doe" },
* ]} />
* <PeopleTiles size="sm" people={volunteers} overflow="scroll" />
*
* With `teams`, each team is fetched in the order given and split
* into a lead group and a members group by the affiliation's
* is_owner flag.
*
* With `peopleslug`, the person is fetched by id and any other key
* on the entry overrides what came back — so a title can be given
* per placement, and is blank when it isn't. A slug entry can sit
* beside a fully-written person in the same array.
*
* Sizes
* sm photo + name
* md photo + name + title
* lg photo + name + title, and a chevron on anyone who has
* something to expand: pronouns, home organization, location,
* public email or a bio. Not bio alone — the details panel is
* worth opening before anyone has written prose.
*
* Field names follow the API (is_owner, location_label), so a row
* from /api/teams/:id/people drops in unchanged.
*/
export interface Person {
id?: string | number;
name: string;
title?: string | null;
tagline?: string | null;
photo?: string | null;
pronouns?: string | null;
location_label?: string | null;
public_email?: string | null;
org?: string | { id?: string; name: string; href?: string } | null;
bio?: string | string[] | null;
accent?: string;
role?: string | null;
is_owner?: boolean;
/** Accepted as an alias so hand-written entries can use either. */
isOwner?: boolean;
}
/** A person named by slug. Any other field overrides the record. */
export interface PersonRef extends Partial<Omit<Person, "name">> {
peopleslug: string;
name?: string;
}
export type PersonInput = Person | PersonRef;
export interface PeopleGroup {
id?: string;
label?: string;
note?: string;
accent?: string;
people: Person[];
}
export interface PeopleGroupInput extends Omit<PeopleGroup, "people"> {
people: PersonInput[];
}
export interface TeamSpec {
id: string;
/** Heading for the members group. Defaults to the team's name. */
label?: string;
/** Heading for the lead group. Defaults to the lead's own title. */
leadLabel?: string;
/** Set false to keep owners inline with everyone else. */
splitOwners?: boolean;
accent?: string;
}
export type TeamSource = string | TeamSpec;
export type PeopleTilesSize = "sm" | "md" | "lg";
export interface PeopleTilesProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
people?: PersonInput[];
groups?: PeopleGroupInput[];
teams?: TeamSource | TeamSource[];
size?: PeopleTilesSize;
scale?: number;
overflow?: "wrap" | "scroll";
align?: "start" | "center";
accent?: string;
tilt?: boolean;
/** How long a fetched team or person is reused, in ms. */
ttl?: number;
emptyMessage?: string;
loadingMessage?: string;
errorMessage?: string;
onExpand?: (person: Person | null, group: PeopleGroup | null) => void;
}
const SIZE_FEATURES: Record<PeopleTilesSize, { title: boolean; details: boolean }> = {
sm: { title: false, details: false },
md: { title: true, details: false },
lg: { title: true, details: true },
};
/* ── Data ──────────────────────────────────────────────────────
Fetching lives here rather than in every section, but the view
below stays pure — it only ever sees resolved groups, whatever
produced them.
───────────────────────────────────────────────────────────── */
export default function PeopleTiles({
teams,
groups,
people,
ttl = 5 * 60_000,
loadingMessage = "Loading…",
errorMessage = "Couldn't load this list right now.",
...view
}: PeopleTilesProps) {
const specs = useMemo(() => normalizeTeams(teams), [teams]);
const teamKey = specs.map((spec) => spec.id).join(",");
// Sorted so two sections naming the same people in a different
// order still hit the same cached request.
const slugs = useMemo(
() => (specs.length ? [] : collectSlugs(groups, people)),
[specs.length, groups, people],
);
const slugKey = slugs.join(",");
const [teamGroups, setTeamGroups] = useState<PeopleGroup[] | null>(null);
const [directory, setDirectory] = useState<Record<string, Person> | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!specs.length) {
setTeamGroups(null);
return undefined;
}
let live = true;
setFailed(false);
Promise.all(
specs.map((spec) =>
get<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
spec,
data,
})),
),
)
.then((results) => {
if (!live) return;
// Order follows the order the teams were supplied in, not
// whichever request came back first.
setTeamGroups(
results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)),
);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load teams", teamKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [teamKey, ttl, specs]);
useEffect(() => {
if (!slugKey) {
setDirectory(null);
return undefined;
}
let live = true;
setFailed(false);
get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data) => {
if (!live) return;
const byId: Record<string, Person> = {};
for (const person of data.people) byId[String(person.id)] = person;
setDirectory(byId);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load people", slugKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [slugKey, ttl]);
if (specs.length) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!teamGroups) return <p className="pl__empty">{loadingMessage}</p>;
return <PeopleTilesView {...view} groups={teamGroups} />;
}
if (slugKey) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!directory) return <p className="pl__empty">{loadingMessage}</p>;
return (
<PeopleTilesView
{...view}
groups={groups?.map((group) => ({
...group,
people: resolveAll(group.people, directory),
}))}
people={people ? resolveAll(people, directory) : undefined}
/>
);
}
return (
<PeopleTilesView
{...view}
groups={groups as PeopleGroup[] | undefined}
people={people as Person[] | undefined}
/>
);
}
interface TeamResponse {
team: { id: string; name: string; color?: string | null };
people: Person[];
}
function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] {
if (!teams) return [];
const list = Array.isArray(teams) ? teams : [teams];
return list
.map((entry) => (typeof entry === "string" ? { id: entry } : entry))
.filter((spec): spec is TeamSpec => Boolean(spec?.id));
}
function isRef(entry: PersonInput): entry is PersonRef {
return typeof (entry as PersonRef).peopleslug === "string";
}
function collectSlugs(
groups?: PeopleGroupInput[],
people?: PersonInput[],
): string[] {
const found = new Set<string>();
const scan = (list?: PersonInput[]) => {
for (const entry of list ?? []) {
if (entry && isRef(entry)) found.add(entry.peopleslug);
}
};
scan(people);
for (const group of groups ?? []) scan(group.people);
return [...found].sort();
}
/* The fetched record is the base; anything else on the entry wins,
including an explicit null — that's how a title is deliberately
left blank rather than inherited. */
function resolveAll(
list: PersonInput[],
directory: Record<string, Person>,
): Person[] {
const resolved: Person[] = [];
for (const entry of list) {
if (!entry) continue;
if (!isRef(entry)) {
resolved.push(entry);
continue;
}
const base = directory[entry.peopleslug];
if (!base) {
// Unpublished, deleted, or a typo in the slug. Leaving the
// tile out beats rendering a nameless placeholder.
console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`);
continue;
}
const { peopleslug, ...overrides } = entry;
const defined: Partial<Person> = Object.fromEntries(
Object.entries(overrides).filter(([, value]) => value !== undefined),
);
resolved.push({ ...base, ...defined });
}
return resolved;
}
function buildTeamGroups(
spec: TeamSpec,
data: TeamResponse,
teamCount: number,
): PeopleGroup[] {
const accent = spec.accent ?? data.team.color ?? undefined;
const name = spec.label ?? data.team.name;
const split = spec.splitOwners !== false;
const owners = split ? data.people.filter(owns) : [];
const rest = split ? data.people.filter((person) => !owns(person)) : data.people;
// A lead only reads as a lead when there's a body of people to
// stand apart from. All owners, or none, is just a list.
if (!owners.length || !rest.length) {
return [
{
id: data.team.id,
// One unlabelled team needs no heading; several always do.
label: teamCount > 1 || spec.label ? name : undefined,
accent,
people: data.people,
},
];
}
return [
{
id: `${data.team.id}-lead`,
label: spec.leadLabel ?? leadLabel(owners, name),
accent,
people: owners,
},
{ id: data.team.id, label: name, accent, people: rest },
];
}
function leadLabel(owners: Person[], teamName: string): string {
if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string;
return `${teamName} lead${owners.length > 1 ? "s" : ""}`;
}
/* ── View ────────────────────────────────────────────────────── */
export function PeopleTilesView({
people,
groups,
size = "md",
scale = 1,
overflow = "wrap",
align = "start",
accent,
tilt = false,
emptyMessage = "No one listed yet.",
onExpand,
className = "",
style,
...rest
}: Omit<
PeopleTilesProps,
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
> & {
people?: Person[];
groups?: PeopleGroup[];
}) {
const baseId = useId().replace(/:/g, "");
const [openKey, setOpenKey] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
const features = SIZE_FEATURES[resolvedSize];
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
const source = groups?.length
? groups
: people?.length
? [{ id: "all", people }]
: [];
return source
.map((group, groupIndex) => ({
...group,
id: group.id ?? `group-${groupIndex}`,
people: (group.people || []).filter(Boolean),
}))
.filter((group) => group.people.length > 0);
}, [groups, people]);
// Close the panel if the person it belongs to disappears.
useEffect(() => {
if (!openKey) return;
const stillThere = resolvedGroups.some((group) =>
group.people.some((person, index) => keyFor(group, person, index) === openKey),
);
if (!stillThere) setOpenKey(null);
}, [openKey, resolvedGroups]);
if (!resolvedGroups.length) {
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
}
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
function toggle(group: PeopleGroup, person: Person, index: number) {
const key = keyFor(group, person, index);
const next = openKey === key ? null : key;
setOpenKey(next);
onExpand?.(next ? person : null, next ? group : null);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && openKey) {
event.stopPropagation();
setOpenKey(null);
const button = rootRef.current?.querySelector<HTMLButtonElement>(
'.pl__tile[aria-expanded="true"]',
);
button?.focus();
}
}
return (
<div
ref={rootRef}
className={`pl ${className}`.trim()}
data-size={resolvedSize}
data-overflow={overflow}
data-align={align}
data-tilt={tilt ? "on" : "off"}
style={
{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
} as CSSProperties
}
onKeyDown={handleKeyDown}
{...rest}
>
<div className="pl__groups">
{resolvedGroups.map((group) => (
<section
key={group.id}
className="pl__group"
style={
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
}
aria-label={group.label || undefined}
>
{(group.label || group.note) && (
<header className="pl__group-head">
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
{group.note && <p className="pl__group-note">{group.note}</p>}
</header>
)}
<ul className="pl__row">
{group.people.map((person, index) => {
const key = keyFor(group, person, index);
const expandable = features.details && hasDetails(person);
const isOpen = expandable && openKey === key;
return (
<li
key={key}
className="pl__item"
style={
person.accent
? ({ "--pl-accent": person.accent } as CSSProperties)
: undefined
}
>
<Tile
person={person}
showTitle={features.title}
expandable={expandable}
isOpen={isOpen}
panelId={`${baseId}-bio`}
onToggle={() => toggle(group, person, index)}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
{open && (
<DetailPanel
id={`${baseId}-bio`}
person={open.person}
group={open.group}
onClose={() => {
setOpenKey(null);
onExpand?.(null, null);
}}
/>
)}
</div>
);
}
function Tile({
person,
showTitle,
expandable,
isOpen,
panelId,
onToggle,
}: {
person: Person;
showTitle: boolean;
expandable: boolean;
isOpen: boolean;
panelId: string;
onToggle: () => void;
}) {
const title = titleOf(person);
const content = (
<span className="pl__frame">
<span className="pl__photo">
<Photo src={photoSrc(person.photo)} name={person.name} />
</span>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && title && <span className="pl__title">{title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
</span>
);
if (!expandable) {
return <div className="pl__tile">{content}</div>;
}
return (
<button
type="button"
className="pl__tile pl__tile--button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
>
{content}
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
</button>
);
}
function Photo({ src, name }: { src?: string | null; name: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return (
<span className="pl__initials" aria-hidden="true">
{initials(name)}
</span>
);
}
return (
<img
className="pl__img"
src={src}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function DetailPanel({
id,
person,
group,
onClose,
}: {
id: string;
person: Person;
group: PeopleGroup;
onClose: () => void;
}) {
const org = resolveOrg(person.org);
const title = titleOf(person);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
const tint = person.accent || group?.accent;
return (
<div
id={id}
className="pl__bio"
role="region"
aria-label={`About ${person.name}`}
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
>
<div className="pl__bio-head">
<div>
<p className="pl__bio-name">{person.name}</p>
{title && <p className="pl__bio-title">{title}</p>}
</div>
<button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close details</span>
<span aria-hidden="true">×</span>
</button>
</div>
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{org && (
<div className="pl__fact">
<dt>Home organization</dt>
<dd>
{org.href ? (
<a href={org.href} target="_blank" rel="noreferrer">
{org.name}
</a>
) : (
org.name
)}
</dd>
</div>
)}
{person.location_label && (
<div className="pl__fact">
<dt>Based in</dt>
<dd>{person.location_label}</dd>
</div>
)}
{person.public_email && (
<div className="pl__fact">
<dt>Email</dt>
<dd>
<a href={`mailto:${person.public_email}`}>{person.public_email}</a>
</dd>
</div>
)}
</dl>
{paragraphs.filter(Boolean).map((paragraph, index) => (
<p key={index} className="pl__bio-text">
{paragraph}
</p>
))}
</div>
);
}
function Chevron() {
return (
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
<path
d="M4 6.5 8 10.5 12 6.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/* ── Helpers ─────────────────────────────────────────────────── */
function keyFor(group: PeopleGroup, person: Person, index: number): string {
return `${group.id}:${person.id ?? person.name ?? index}`;
}
function findByKey(groups: PeopleGroup[], key: string | null) {
if (!key) return null;
for (const group of groups) {
for (let index = 0; index < group.people.length; index += 1) {
const person = group.people[index];
if (keyFor(group, person, index) === key) return { group, person };
}
}
return null;
}
/* The seat they hold here, or what they're called when there is no
seat — the same fallback content.js applies to leadership rows. */
function titleOf(person: Person): string | null {
return person.title || person.tagline || null;
}
function owns(person: Person): boolean {
return Boolean(person.is_owner ?? person.isOwner);
}
/* Anything the panel would have to show. Gating on bio alone left
every tile flat until someone wrote prose. */
function hasDetails(person: Person): boolean {
if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) {
return true;
}
return Boolean(
person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email,
);
}
/* Database rows carry a bare filename; a hand-written entry may
give a path or a full URL. Both should work. */
function photoSrc(photo?: string | null): string | null {
if (!photo) return null;
if (/^(https?:|\/|data:)/.test(photo)) return photo;
return `/people/${photo}`;
}
function initials(name = ""): string {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] || "")
.join("")
.toUpperCase();
}
function resolveOrg(org: Person["org"]) {
if (!org) return null;
if (typeof org === "string") return { name: org, href: undefined };
if (!org.name) return null;
return org;
}