NGU-Web/src/components/PeopleTiles.tsx
Zaldimmar 22d5328885 Convert src/ JavaScript modules to TypeScript
Renames every .js module under src/ to .ts (api, useResource,
adminSchema, navConfig, adminNav and src/data/*) and points imports,
comments and the seed script's module paths at the new names. Their
types now come from inference; no .d.ts files and no shape
interfaces.

tsc stays strict (noImplicitAny off). The errors inference leaves
behind get the lightest fix that clears them: `any` on empty state,
contexts and list defaults, `: any` on components with optional or
spread props, class fields on ApiError, and option shapes on
get/useResource.

Kept as real types, since they belong to modules that were already
TypeScript and later features import them: PageShell's ShellSection
and props, useContent's corrected record types (website/email/
instagram are bare strings) and EventListItem, and TimelineRef's
orgKind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:39:42 -05:00

786 lines
22 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 { Link } from "react-router-dom";
import { get } from "../lib/api.ts";
import { isBadId, personHref } from "../lib/hrefs.ts";
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.
*
* Profiles
* A person with a string id is taken to be a people row and links
* to /people/:id. A tile with nothing to expand is that link; an
* expandable one stays the button that opens its panel — a link
* can't sit inside a button — and the panel carries the link
* instead. A hand-written entry with no id links nowhere.
*/
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(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
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?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data: { people: Person[] }) => {
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 merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as any)[key] = value;
}
resolved.push(merged);
}
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) {
const href = profileHref(person);
return href ? (
<Link to={href} className="pl__tile pl__tile--link">
{content}
</Link>
) : (
<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;
const profile = profileHref(person);
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>
))}
{profile && (
<Link to={profile} className="pl__profile">
View full profile →
</Link>
)}
</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 ─────────────────────────────────────────────────── */
/* A string id is a people slug; a numeric or missing one is a
hand-written entry with no page behind it. */
function profileHref(person: Person): string | null {
return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null;
}
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;
}