v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

View file

@ -1,48 +1,370 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
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.
*
* Drop into any section:
* <PeopleTiles size="md" groups={staffGroups} />
* <PeopleTiles size="sm" people={volunteers} />
* 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 + expandable bio (pronouns, age, primary org above the bio)
* 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.
*
* Group shape
* { id, label?, note?, accent?, people: [person] }
*
* Person shape
* {
* id, name,
* title?, // "Regional Director"
* photo?, // "/people/jane-doe.jpg" — falls back to initials
* pronouns?, // "she/her"
* age?, // number, or use birthdate
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
* org?, // "Grace Chapel" or { name, href }
* bio?, // string or string[] (paragraphs)
* accent?, // per-person override
* }
* Field names follow the API (is_owner, location_label), so a row
* from /api/teams/:id/people drops in unchanged.
*/
const SIZE_FEATURES = {
sm: { title: false, bio: false },
md: { title: true, bio: false },
lg: { title: true, bio: true },
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 Record<string, unknown>)[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", // "wrap" | "scroll"
align = "start", // "start" | "center"
overflow = "wrap",
align = "start",
accent,
tilt = false,
emptyMessage = "No one listed yet.",
@ -50,18 +372,24 @@ export default function PeopleTiles({
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 [openKey, setOpenKey] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
const features = SIZE_FEATURES[resolvedSize];
const resolvedGroups = useMemo(() => {
const source = Array.isArray(groups) && groups.length
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
const source = groups?.length
? groups
: Array.isArray(people) && people.length
: people?.length
? [{ id: "all", people }]
: [];
@ -74,7 +402,7 @@ export default function PeopleTiles({
.filter((group) => group.people.length > 0);
}, [groups, people]);
// Close the bio if the person it belongs to disappears from the data.
// Close the panel if the person it belongs to disappears.
useEffect(() => {
if (!openKey) return;
const stillThere = resolvedGroups.some((group) =>
@ -87,21 +415,23 @@ export default function PeopleTiles({
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
}
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
function toggle(group, person, index) {
function toggle(group: PeopleGroup, person: Person, index: number) {
const key = keyFor(group, person, index);
const next = openKey === key ? null : key;
setOpenKey(next);
if (onExpand) onExpand(next ? person : null, next ? group : null);
onExpand?.(next ? person : null, next ? group : null);
}
function handleKeyDown(event) {
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && openKey) {
event.stopPropagation();
setOpenKey(null);
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
if (button) button.focus();
const button = rootRef.current?.querySelector<HTMLButtonElement>(
'.pl__tile[aria-expanded="true"]',
);
button?.focus();
}
}
@ -113,11 +443,13 @@ export default function PeopleTiles({
data-overflow={overflow}
data-align={align}
data-tilt={tilt ? "on" : "off"}
style={{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
}}
style={
{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
} as CSSProperties
}
onKeyDown={handleKeyDown}
{...rest}
>
@ -126,7 +458,9 @@ export default function PeopleTiles({
<section
key={group.id}
className="pl__group"
style={group.accent ? { "--pl-accent": group.accent } : undefined}
style={
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
}
aria-label={group.label || undefined}
>
{(group.label || group.note) && (
@ -139,14 +473,18 @@ export default function PeopleTiles({
<ul className="pl__row">
{group.people.map((person, index) => {
const key = keyFor(group, person, index);
const expandable = features.bio && hasBio(person);
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 } : undefined}
style={
person.accent
? ({ "--pl-accent": person.accent } as CSSProperties)
: undefined
}
>
<Tile
person={person}
@ -165,13 +503,13 @@ export default function PeopleTiles({
</div>
{open && (
<BioPanel
<DetailPanel
id={`${baseId}-bio`}
person={open.person}
group={open.group}
onClose={() => {
setOpenKey(null);
if (onExpand) onExpand(null, null);
onExpand?.(null, null);
}}
/>
)}
@ -179,24 +517,38 @@ export default function PeopleTiles({
);
}
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
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={person.photo} name={person.name} />
</span>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
<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) {
@ -212,12 +564,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
onClick={onToggle}
>
{content}
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
</button>
);
}
function Photo({ src, name }) {
function Photo({ src, name }: { src?: string | null; name: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
@ -244,10 +596,21 @@ function Photo({ src, name }) {
);
}
function BioPanel({ id, person, group, onClose }) {
const age = resolveAge(person);
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
@ -255,49 +618,55 @@ function BioPanel({ id, person, group, onClose }) {
className="pl__bio"
role="region"
aria-label={`About ${person.name}`}
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
>
<div className="pl__bio-head">
<div>
<p className="pl__bio-name">{person.name}</p>
{person.title && <p className="pl__bio-title">{person.title}</p>}
{title && <p className="pl__bio-title">{title}</p>}
</div>
<button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close bio</span>
<span className="pl__sr">Close details</span>
<span aria-hidden="true">×</span>
</button>
</div>
{(person.pronouns || age != null || org) && (
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{age != null && (
<div className="pl__fact">
<dt>Age</dt>
<dd>{age}</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>
)}
</dl>
)}
<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">
@ -323,13 +692,13 @@ function Chevron() {
);
}
/* helpers ------------------------------------------------------------- */
/* ── Helpers ─────────────────────────────────────────────────── */
function keyFor(group, person, index) {
function keyFor(group: PeopleGroup, person: Person, index: number): string {
return `${group.id}:${person.id ?? person.name ?? index}`;
}
function findByKey(groups, key) {
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) {
@ -340,12 +709,36 @@ function findByKey(groups, key) {
return null;
}
function hasBio(person) {
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
return Boolean(person.bio);
/* 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 initials(name = "") {
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+/)
@ -355,21 +748,9 @@ function initials(name = "") {
.toUpperCase();
}
function resolveAge(person) {
if (typeof person.age === "number") return person.age;
if (!person.birthdate) return null;
const born = new Date(person.birthdate);
if (Number.isNaN(born.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - born.getFullYear();
const monthDelta = now.getMonth() - born.getMonth();
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
return age >= 0 ? age : null;
}
function resolveOrg(org) {
function resolveOrg(org: Person["org"]) {
if (!org) return null;
if (typeof org === "string") return { name: org };
if (typeof org === "string") return { name: org, href: undefined };
if (!org.name) return null;
return org;
}