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: * * * * * * * 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> { 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 { 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, "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 = { 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(null); const [directory, setDirectory] = useState | 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) => ({ 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 = {}; 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

{errorMessage}

; if (!teamGroups) return

{loadingMessage}

; return ; } if (slugKey) { if (failed) return

{errorMessage}

; if (!directory) return

{loadingMessage}

; return ( ({ ...group, people: resolveAll(group.people, directory), }))} people={people ? resolveAll(people, directory) : undefined} /> ); } return ( ); } 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(); 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, ): 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 = 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(null); const rootRef = useRef(null); const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md"; const features = SIZE_FEATURES[resolvedSize]; const resolvedGroups = useMemo(() => { 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 ?

{emptyMessage}

: 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) { if (event.key === "Escape" && openKey) { event.stopPropagation(); setOpenKey(null); const button = rootRef.current?.querySelector( '.pl__tile[aria-expanded="true"]', ); button?.focus(); } } return (
{resolvedGroups.map((group) => (
{(group.label || group.note) && (
{group.label &&

{group.label}

} {group.note &&

{group.note}

}
)}
    {group.people.map((person, index) => { const key = keyFor(group, person, index); const expandable = features.details && hasDetails(person); const isOpen = expandable && openKey === key; return (
  • toggle(group, person, index)} />
  • ); })}
))}
{open && ( { setOpenKey(null); onExpand?.(null, null); }} /> )}
); } 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 = ( {person.name} {showTitle && title && {title}} {expandable && ( )} ); if (!expandable) { return
{content}
; } return ( ); } function Photo({ src, name }: { src?: string | null; name: string }) { const [failed, setFailed] = useState(false); useEffect(() => { setFailed(false); }, [src]); if (!src || failed) { return ( ); } return ( 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 (

{person.name}

{title &&

{title}

}
{person.pronouns && (
Pronouns
{person.pronouns}
)} {org && (
Home organization
{org.href ? ( {org.name} ) : ( org.name )}
)} {person.location_label && (
Based in
{person.location_label}
)} {person.public_email && ( )}
{paragraphs.filter(Boolean).map((paragraph, index) => (

{paragraph}

))}
); } function Chevron() { return ( ); } /* ── 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; }