From 6ec2fb240a40b7c314b60f00bf3a4efc0e2e4e4c Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 05:05:45 -0500 Subject: [PATCH] Add person detail page and link people tiles to it GET /api/people/:id returns a published person's profile, public roles (current and past), published events they were billed at or hosted, and public awards, each under the same visibility rules its own page applies. public_phone is never sent. PersonDetail renders it at /people/:id, the route personHref already pointed at. PeopleTiles links a tile with a people id to that page; an expandable tile keeps its details panel and the panel carries a "View full profile" link instead. Co-Authored-By: Claude Opus 5.5 --- server/src/routes/people.js | 160 ++++++++++++++- src/App.tsx | 2 + src/components/PeopleTiles.css | 32 ++- src/components/PeopleTiles.tsx | 32 ++- src/lib/useContent.ts | 60 ++++++ src/pages/PersonDetail.tsx | 363 +++++++++++++++++++++++++++++++++ 6 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 src/pages/PersonDetail.tsx diff --git a/server/src/routes/people.js b/server/src/routes/people.js index e89ebca..dbc31d9 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,6 +3,7 @@ GET /teams/:id/people current public members of a team GET /people?ids=a,b,c named people, any order + GET /people/:id one person's page The team route reads v_org_leadership, which already decides who counts as current and public — affiliation still open, marked @@ -20,7 +21,7 @@ import { Hono } from "hono"; -import { asBool } from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const people = new Hono(); @@ -138,4 +139,161 @@ people.get("/people", (c) => { return json(c, { people: rows.map(shapePerson) }); }); +/* ── One person's page ───────────────────────────────────────── + Everything public that points at this person, each list with + the same visibility rules its own page applies: a role needs a + public affiliation and a published organization, an event must + be published, an award must be published and the citation + public. A hidden team drops its name rather than the role — + the seat is still real, it just has no page to link to. + + Roles are current and past. v_org_leadership only knows + current, which is what a roster wants and not what a person's + record does, so this reads affiliations directly. + + Events merge two tables: event_people (who was billed, and as + what) and event_hosts (who ran it). One person can be both at + one event, so they collapse to one row carrying every role. + ───────────────────────────────────────────────────────────── */ + +people.get("/people/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name, + o.kind AS primary_org_kind + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 + WHERE p.id = ? AND p.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such person" }, 404); + + const links = loadLinks(db, "person", [id]).get(id) ?? []; + const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; + const { actions, socials, website, instagram } = splitLinks(links); + + // Current first, then most recently ended. Within each, the same + // order a roster uses: owner, then the affiliation's sort_order. + const roles = db + .prepare( + `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, + o.id AS org_id, o.name AS org_name, o.kind AS org_kind, + t.id AS team_id, t.name AS team_name + FROM affiliations a + JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 + WHERE a.person_id = ? AND a.is_public = 1 + ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, + a.is_owner DESC, a.sort_order, o.sort_order`, + ) + .all(id) + .map((r) => ({ + title: r.title, + role: r.role, + is_owner: asBool(r.is_owner), + started_on: r.started_on, + ended_on: r.ended_on, + org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, + team: r.team_id ? { id: r.team_id, name: r.team_name } : null, + })); + + const eventRows = db + .prepare( + `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, + e.effective_status AS status, x.role, x.title AS billing + FROM ( + SELECT event_id, role, title, sort_order + FROM event_people + WHERE person_id = ? AND is_public = 1 + UNION ALL + SELECT event_id, 'host', NULL, -1 + FROM event_hosts + WHERE person_id = ? + ) x + JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 + ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + export default people; diff --git a/src/App.tsx b/src/App.tsx index 9b7efd4..ff85789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import EventDetail from './pages/EventDetail.tsx' import OrganizationDetail from './pages/OrganizationDetail.tsx' import TeamDetail from './pages/TeamDetail.tsx' import AwardDetail from './pages/AwardDetail.tsx' +import PersonDetail from './pages/PersonDetail.tsx' /*Admin Pages*/ import AdminLayout from "./pages/admin/AdminLayout.tsx"; @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index 2f70d5d..ecff0d2 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,10 +123,16 @@ text-align: inherit; } -.pl__tile--button { +.pl__tile--button, +.pl__tile--link { cursor: pointer; } +.pl__tile--link { + color: inherit; + text-decoration: none; +} + .pl__frame { position: relative; display: flex; @@ -150,16 +156,20 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:hover .pl__frame, +.pl__tile--link:focus-visible .pl__frame { transform: translateY(-2px); box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); } -.pl__tile--button:focus-visible { +.pl__tile--button:focus-visible, +.pl__tile--link:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -311,6 +321,20 @@ max-width: 62ch; } +.pl__profile { + display: inline-block; + margin-top: 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--pl-accent); + text-decoration: none; +} + +.pl__profile:hover, +.pl__profile:focus-visible { + text-decoration: underline; +} + .pl__empty { margin: 0; font-size: 0.9375rem; diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index bd738a9..62dcf4f 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -8,7 +8,10 @@ import { type HTMLAttributes, } from "react"; +import { Link } from "react-router-dom"; + import { get } from "../lib/api.js"; +import { isBadId, personHref } from "../lib/hrefs.ts"; import "./PeopleTiles.css"; /** @@ -43,6 +46,13 @@ import "./PeopleTiles.css"; * * Field names follow the API (is_owner, location_label), so a row * from /api/teams/:id/people drops in unchanged. + * + * Profiles + * A person with a string id is taken to be a people row and links + * to /people/:id. A tile with nothing to expand is that link; an + * expandable one stays the button that opens its panel — a link + * can't sit inside a button — and the panel carries the link + * instead. A hand-written entry with no id links nowhere. */ export interface Person { @@ -551,7 +561,14 @@ function Tile({ ); if (!expandable) { - return
{content}
; + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); } return ( @@ -610,6 +627,7 @@ function DetailPanel({ const title = titleOf(person); const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; const tint = person.accent || group?.accent; + const profile = profileHref(person); return (
))} + + {profile && ( + + View full profile → + + )}
); } @@ -693,6 +717,12 @@ function Chevron() { /* ── Helpers ─────────────────────────────────────────────────── */ +/* A string id is a people slug; a numeric or missing one is a + hand-written entry with no page behind it. */ +function profileHref(person: Person): string | null { + return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null; +} + function keyFor(group: PeopleGroup, person: Person, index: number): string { return `${group.id}:${person.id ?? person.name ?? index}`; } diff --git a/src/lib/useContent.ts b/src/lib/useContent.ts index 025dc94..ddf380e 100644 --- a/src/lib/useContent.ts +++ b/src/lib/useContent.ts @@ -276,3 +276,63 @@ export type AwardRecord = { export const useAward = (id?: string): Resource => useRecord(detailPath('/awards', id), 'award') + +/* ── People ──────────────────────────────────────────────────── */ + +/** One public affiliation. ended_on null means current. */ +export type PersonRole = { + title?: string | null + role: 'lead' | 'board' | 'staff' | 'volunteer' | 'member' + is_owner: boolean + started_on?: string | null + ended_on?: string | null + org: OrgRef + /** Null when there's no team, or the team is unpublished. */ + team: { id: string; name: string } | null +} + +/** A published event this person was billed at or hosted, with + * every capacity they appeared in. 'host' comes from event_hosts. */ +export type PersonEvent = { + id: string + title: string + event_type: EventType + date_label?: string | null + starts_on?: string | null + status: 'upcoming' | 'past' | 'cancelled' + roles: { role: string; title?: string | null }[] +} + +export type PersonAward = { + award: { id: string; name: string; logo?: string | null } + awarded_on?: string | null + citation?: string | null + event: { id: string; title: string } | null +} + +export type PersonRecord = { + id: string + name: string + pronouns?: string | null + tagline?: string | null + photo?: string | null + location_label?: string | null + public_email?: string | null + /** The primary organization, when it's published. */ + org: OrgRef | null + /** people.bio split on blank lines. */ + bio: string[] + description: string[] + blocks: ContentBlock[] + links: Link[] + socials: Link[] + website?: string | null + instagram?: string | null + /** Current first, then most recently ended. */ + roles: PersonRole[] + events: PersonEvent[] + awards: PersonAward[] +} + +export const usePerson = (id?: string): Resource => + useRecord(detailPath('/people', id), 'person') diff --git a/src/pages/PersonDetail.tsx b/src/pages/PersonDetail.tsx new file mode 100644 index 0000000..ecf4e55 --- /dev/null +++ b/src/pages/PersonDetail.tsx @@ -0,0 +1,363 @@ +/* ═══════════════════════════════════════════════════════════════ + PERSON DETAIL — /people/:id + + Everything public that points at one person, gathered by + GET /people/:id in people.js. Visibility is decided there, the + same way each list's own page decides it, so nothing here + filters. + + Roles are current and past. A roster only ever wants who holds + a seat now; a person's record is also where "served on the board + 2018–2022" belongs, so ended affiliations list under Previously. + + public_phone is never sent. The email is the one contact detail + the page offers. + + Sections after About alternate background in the order they + appear, so a person with no roles doesn't get two tinted bands + in a row. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, useParams } from 'react-router-dom' + +import PageShell, { type ShellSection } from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import ContentBlocks from '../components/ContentBlocks.tsx' +import { + usePerson, + type PersonEvent, + type PersonRecord, + type PersonRole, +} from '../lib/useContent.ts' +import { awardHref, eventHref, orgHref, teamHref } from '../lib/hrefs.ts' +import { initials, personPhoto } from '../lib/media.ts' +import { eventTypeLabel } from '../lib/eventTypes.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' +const BACKGROUNDS = ['#ffffff', '#eef9fb'] + +/* affiliations.role, for a row with no title of its own. */ +const ROLE_LABEL: Record = { + lead: 'Lead', + board: 'Board member', + staff: 'Staff', + volunteer: 'Volunteer', + member: 'Member', +} + +const capitalize = (word: string) => + word ? word.charAt(0).toUpperCase() + word.slice(1) : '' + +export default function PersonDetail() { + const { id } = useParams() + const { data: person, loading, error, notFound, reload } = usePerson(id) + + if (!person) { + return ( + + ) + } + + const accent = TEAL + const current = person.roles.filter((role) => !role.ended_on) + const previous = person.roles.filter((role) => role.ended_on) + + const sections: ShellSection[] = [ + { + id: 'about', + title: 'About', + accent, + background: BACKGROUNDS[0], + content: ( +
+ + + {[...person.bio, ...person.description].map((paragraph, index) => ( +

+ {paragraph} +

+ ))} + +
+ +
+ + +
+ ), + }, + ] + + if (person.roles.length > 0) { + sections.push({ + id: 'roles', + title: 'Roles', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+ {current.length > 0 && } + + {previous.length > 0 && ( +
+ {current.length > 0 && ( +

+ Previously +

+ )} + +
+ )} +
+ ), + }) + } + + if (person.events.length > 0) { + sections.push({ + id: 'events', + title: 'Events', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+
    + {person.events.map((event) => ( +
  • + +
  • + ))} +
+
+ ), + }) + } + + if (person.awards.length > 0) { + sections.push({ + id: 'awards', + title: 'Awards', + accent, + background: BACKGROUNDS[sections.length % 2], + content: ( +
+ {person.awards.map((entry, index) => ( +
+

+ + {entry.award.name} + +

+ {(entry.awarded_on || entry.event) && ( +

+ {year(entry.awarded_on)} + {entry.event && ( + <> + {entry.awarded_on && ' · '} + + {entry.event.title} + + + )} +

+ )} + {entry.citation && ( +

+ {entry.citation} +

+ )} +
+ ))} +
+ ), + }) + } + + return ( + + ) +} + +/* ── The strip of facts under the heading ────────────────────── */ + +function Facts({ person, accent }: { person: PersonRecord; accent: string }) { + const photo = personPhoto(person.photo) + + return ( +
+ {photo ? ( + + ) : ( + + )} + + {person.pronouns && {person.pronouns}} + + {person.org && ( + + {person.org.name} + + )} + + {person.location_label && {person.location_label}} + + + Leadership + +
+ ) +} + +function Contact({ person, accent }: { person: PersonRecord; accent: string }) { + const outlined = [ + person.website ? { url: person.website, label: 'Website' } : null, + person.public_email + ? { url: `mailto:${person.public_email}`, label: person.public_email } + : null, + ...person.socials, + ].filter((link): link is NonNullable => Boolean(link)) + + if (person.links.length === 0 && outlined.length === 0) return null + + return ( +
+ {person.links.map((link) => ( + + {link.label} + + ))} + + {outlined.map((link) => ( + + {link.label} + + ))} +
+ ) +} + +/* ── Roles ───────────────────────────────────────────────────── */ + +function RoleList({ roles, accent }: { roles: PersonRole[]; accent: string }) { + return ( +
    + {roles.map((role, index) => ( +
  • +

    + {role.title || ROLE_LABEL[role.role] || capitalize(role.role)} +

    +

    + {role.team && ( + <> + + {role.team.name} + + {' · '} + + )} + + {role.org.name} + +

    + {tenure(role) && ( +

    + {tenure(role)} +

    + )} +
  • + ))} +
+ ) +} + +/* "2018 – 2022", "Since 2021", "Until 2019", or null. Years only: + affiliation dates are often backfilled from memory. */ +function tenure(role: PersonRole): string | null { + const from = year(role.started_on) + const to = year(role.ended_on) + if (from && to) return from === to ? from : `${from} – ${to}` + if (from) return `Since ${from}` + if (to) return `Until ${to}` + return null +} + +/* ── Events ──────────────────────────────────────────────────── */ + +function EventRow({ event, accent }: { event: PersonEvent; accent: string }) { + const when = event.date_label || year(event.starts_on) + const capacities = event.roles + .map((role) => role.title || capitalize(role.role)) + .join(', ') + + return ( +
+

+ + {event.title} + +

+

+ {[capacities, eventTypeLabel(event.event_type), when].filter(Boolean).join(' · ')} +

+
+ ) +} + +/* Dates here may be partial ('2019', '2019-06'), so take the + leading year rather than parsing. */ +function year(date?: string | null): string | null { + if (!date) return null + const match = /^(\d{4})/.exec(date) + return match ? match[1] : date +} -- 2.49.1