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>
755 lines
21 KiB
TypeScript
755 lines
21 KiB
TypeScript
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;
|
||
}
|