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 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-25 05:05:45 -05:00
parent 300c9b74f0
commit 6ec2fb240a
6 changed files with 643 additions and 6 deletions

View file

@ -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;