v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
418
src/components/admin/fields.tsx
Normal file
418
src/components/admin/fields.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN FORM PRIMITIVES
|
||||
|
||||
Field renders one input from a manifest entry. Repeater renders
|
||||
an ordered collection of them, and nests one level for content
|
||||
blocks and their items.
|
||||
|
||||
Ordering is array position — the server writes sort_order from
|
||||
the index — so moving a row is a splice, not a number to
|
||||
hand-edit. Rows can be dragged by the handle or moved with the
|
||||
arrow buttons; the arrows are the keyboard path and stay whether
|
||||
or not a pointer is in use.
|
||||
|
||||
A row added and never filled in is dropped by the server rather
|
||||
than rejected, which depends on spec.blank seeding nothing the
|
||||
server doesn't also declare as a column default. If you give a
|
||||
blank row a starting value here, add the matching default: to
|
||||
that column in the server's admin-schema.js or the row will be
|
||||
saved as real input.
|
||||
|
||||
Two options change the box itself rather than what goes in it:
|
||||
|
||||
prefix fixed text inside the box, left of the cursor. The
|
||||
value it decorates is only the part after it, so
|
||||
the caller joins the two. Used by composed slugs,
|
||||
where the prefix is a fact about another field
|
||||
rather than something to retype.
|
||||
readOnly shown, selectable, not editable. Deliberately not
|
||||
`disabled`: a disabled control reads as switched
|
||||
off and drops out of the tab order, whereas an
|
||||
immutable id is settled fact you still want to be
|
||||
able to read and copy.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const input =
|
||||
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
||||
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
/* Same box, but lit by the real input nested inside it. */
|
||||
const inputShell =
|
||||
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
|
||||
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
|
||||
"focus-within:ring-[#138ba0]/25";
|
||||
|
||||
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||
const shellError =
|
||||
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
|
||||
|
||||
/* Reads as settled fact rather than as an empty box someone forgot
|
||||
to fill in. */
|
||||
const inputLocked =
|
||||
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
|
||||
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
|
||||
|
||||
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||
|
||||
export function getPath(object, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
export function setPath(object, path, value) {
|
||||
const [head, ...rest] = path.split(".");
|
||||
if (rest.length === 0) return { ...object, [head]: value };
|
||||
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
||||
}
|
||||
|
||||
/* ── Field ───────────────────────────────────────────────────── */
|
||||
|
||||
export function Field({ field, value, row, options, error, onChange }) {
|
||||
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||
const widget = field.widget ?? "text";
|
||||
const locked = Boolean(field.readOnly);
|
||||
|
||||
let list = null;
|
||||
let orphaned = false;
|
||||
|
||||
if (widget === "select") {
|
||||
list = field.optionsFrom
|
||||
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
||||
: (field.options ?? []).map((o) =>
|
||||
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
||||
);
|
||||
if (field.filterBy && row) {
|
||||
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
||||
}
|
||||
|
||||
// A stored value with no matching option renders as the blank
|
||||
// choice, which reads as "nobody set this" and saves as a
|
||||
// deliberate clear. It usually means the row it pointed at was
|
||||
// deleted, so keep it on screen and say so.
|
||||
orphaned =
|
||||
value != null &&
|
||||
value !== "" &&
|
||||
!list.some(([optionId]) => String(optionId) === String(value));
|
||||
}
|
||||
|
||||
const common = {
|
||||
id,
|
||||
className: `${input} ${error ? inputError : ""}`,
|
||||
value: value ?? "",
|
||||
onChange: (e) => onChange(e.target.value),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={field.full ? "sm:col-span-2" : ""}>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
{field.label}
|
||||
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
|
||||
</label>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{widget === "checkbox" ? (
|
||||
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={value === 1 || value === true}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
||||
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
|
||||
/>
|
||||
{field.help ?? "Yes"}
|
||||
</label>
|
||||
) : widget === "textarea" ? (
|
||||
<textarea
|
||||
{...common}
|
||||
rows={4}
|
||||
readOnly={locked}
|
||||
className={`${locked ? inputLocked : common.className} resize-y`}
|
||||
/>
|
||||
) : widget === "select" ? (
|
||||
// A select has no readOnly, so this one really does have
|
||||
// to be disabled — there's no way to keep it focusable
|
||||
// and still refuse a new choice.
|
||||
<select
|
||||
{...common}
|
||||
disabled={locked}
|
||||
className={`${locked ? inputLocked : common.className} ${
|
||||
orphaned && !locked ? inputError : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||
{list.map(([id2, label]) => (
|
||||
<option key={id2} value={id2}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : widget === "color" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
{...common}
|
||||
readOnly={locked}
|
||||
placeholder="#138ba0"
|
||||
className={locked ? inputLocked : common.className}
|
||||
/>
|
||||
</div>
|
||||
) : field.prefix && !locked ? (
|
||||
// The span is not a form control, so it can't be typed
|
||||
// into, tabbed to, or selected by dragging through the
|
||||
// field. Clicking it focuses the input, which is what
|
||||
// makes the two read as one box.
|
||||
<div
|
||||
className={`${inputShell} ${error ? shellError : ""}`}
|
||||
onClick={() => document.getElementById(id)?.focus()}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 select-none ${
|
||||
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
|
||||
}`}
|
||||
>
|
||||
{field.prefix}
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
||||
step={widget === "number" ? "any" : undefined}
|
||||
{...common}
|
||||
readOnly={locked}
|
||||
aria-readonly={locked || undefined}
|
||||
className={locked ? inputLocked : common.className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
|
||||
) : orphaned && !locked ? (
|
||||
<p className="mt-1 text-xs text-[#b3261e]">
|
||||
This points at something that has been deleted. Pick a replacement before saving.
|
||||
</p>
|
||||
) : (
|
||||
field.help &&
|
||||
widget !== "checkbox" && (
|
||||
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldGrid({ children }) {
|
||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
/* ── Repeater ────────────────────────────────────────────────── */
|
||||
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||
const list = rows ?? [];
|
||||
|
||||
// Which row is in flight, and which one it's currently over.
|
||||
// Both are per-Repeater, which is what keeps a drag inside a
|
||||
// nested collection from being accepted by the outer one.
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
const [overIndex, setOverIndex] = useState(null);
|
||||
const rowRefs = useRef([]);
|
||||
|
||||
const update = (index, next) =>
|
||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||
|
||||
const move = (index, delta) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= list.length) return;
|
||||
const next = [...list];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const relocate = (from, to) => {
|
||||
if (from === to || from == null || to == null) return;
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...list, { ...spec.blank }])}
|
||||
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
{spec.addLabel ?? "Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
|
||||
|
||||
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{list.map((row, index) => {
|
||||
const dragging = dragIndex === index;
|
||||
const over = overIndex === index && dragIndex !== null && !dragging;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A null dragIndex means the drag started in some
|
||||
// other collection — leave it to whoever owns it.
|
||||
if (dragIndex === null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (overIndex !== index) setOverIndex(index);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (dragIndex === null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
relocate(dragIndex, index);
|
||||
endDrag();
|
||||
}}
|
||||
className={
|
||||
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
|
||||
(over
|
||||
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
|
||||
: "border-[#4a6b72]/20 ") +
|
||||
(dragging ? "opacity-50" : "")
|
||||
}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox won't start a drag without payload.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
// Drag the whole row, not just the handle.
|
||||
const el = rowRefs.current[index];
|
||||
if (el) e.dataTransfer.setDragImage(el, 16, 16);
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
title="Drag to reorder"
|
||||
aria-hidden="true"
|
||||
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
|
||||
>
|
||||
⠿
|
||||
</span>
|
||||
|
||||
<span className="text-sm font-medium text-[#26454c]">
|
||||
{spec.title ? spec.title(row, options) : `#${index + 1}`}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex gap-1">
|
||||
<IconButton
|
||||
label="Move up"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Move down"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === list.length - 1}
|
||||
>
|
||||
↓
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Remove"
|
||||
danger
|
||||
onClick={() => onChange(list.filter((_, i) => i !== index))}
|
||||
>
|
||||
✕
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGrid>
|
||||
{spec.fields.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
row={row}
|
||||
value={row[field.path]}
|
||||
options={options}
|
||||
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
|
||||
onChange={(value) => update(index, { ...row, [field.path]: value })}
|
||||
/>
|
||||
))}
|
||||
</FieldGrid>
|
||||
|
||||
{(spec.children ?? []).map((nested) => (
|
||||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||
<Repeater
|
||||
spec={nested}
|
||||
rows={row[nested.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||
onChange={(value) => update(index, { ...row, [nested.key]: value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
|
||||
(disabled
|
||||
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
|
||||
: danger
|
||||
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
|
||||
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue