Add person detail page and link people tiles to it #6

Merged
ngu-git-admin merged 1 commit from feature/person-detail into main 2026-09-25 11:13:30 +01:00
6 changed files with 643 additions and 6 deletions
Showing only changes of commit 6ec2fb240a - Show all commits

View file

@ -3,6 +3,7 @@
GET /teams/:id/people current public members of a team GET /teams/:id/people current public members of a team
GET /people?ids=a,b,c named people, any order 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 The team route reads v_org_leadership, which already decides who
counts as current and public — affiliation still open, marked counts as current and public — affiliation still open, marked
@ -20,7 +21,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { asBool } from "../shape.js"; import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
const people = new Hono(); const people = new Hono();
@ -138,4 +139,161 @@ people.get("/people", (c) => {
return json(c, { people: rows.map(shapePerson) }); 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; export default people;

View file

@ -23,6 +23,7 @@ import EventDetail from './pages/EventDetail.tsx'
import OrganizationDetail from './pages/OrganizationDetail.tsx' import OrganizationDetail from './pages/OrganizationDetail.tsx'
import TeamDetail from './pages/TeamDetail.tsx' import TeamDetail from './pages/TeamDetail.tsx'
import AwardDetail from './pages/AwardDetail.tsx' import AwardDetail from './pages/AwardDetail.tsx'
import PersonDetail from './pages/PersonDetail.tsx'
/*Admin Pages*/ /*Admin Pages*/
import AdminLayout from "./pages/admin/AdminLayout.tsx"; import AdminLayout from "./pages/admin/AdminLayout.tsx";
@ -54,6 +55,7 @@ export default function App() {
<Route path="/organizations/:id" element={<OrganizationDetail />} /> <Route path="/organizations/:id" element={<OrganizationDetail />} />
<Route path="/teams/:id" element={<TeamDetail />} /> <Route path="/teams/:id" element={<TeamDetail />} />
<Route path="/awards/:id" element={<AwardDetail />} /> <Route path="/awards/:id" element={<AwardDetail />} />
<Route path="/people/:id" element={<PersonDetail />} />
<Route path="leadership" element={<Leadership />} /> <Route path="leadership" element={<Leadership />} />
<Route path="resources" element={<Resources />} /> <Route path="resources" element={<Resources />} />
<Route path="history" element={<History />} /> <Route path="history" element={<History />} />

View file

@ -123,10 +123,16 @@
text-align: inherit; text-align: inherit;
} }
.pl__tile--button { .pl__tile--button,
.pl__tile--link {
cursor: pointer; cursor: pointer;
} }
.pl__tile--link {
color: inherit;
text-decoration: none;
}
.pl__frame { .pl__frame {
position: relative; position: relative;
display: flex; display: flex;
@ -150,16 +156,20 @@
} }
.pl__tile--button:hover .pl__frame, .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); transform: translateY(-2px);
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); 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; 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: 2px solid var(--pl-accent);
outline-offset: 3px; outline-offset: 3px;
} }
@ -311,6 +321,20 @@
max-width: 62ch; 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 { .pl__empty {
margin: 0; margin: 0;
font-size: 0.9375rem; font-size: 0.9375rem;

View file

@ -8,7 +8,10 @@ import {
type HTMLAttributes, type HTMLAttributes,
} from "react"; } from "react";
import { Link } from "react-router-dom";
import { get } from "../lib/api.js"; import { get } from "../lib/api.js";
import { isBadId, personHref } from "../lib/hrefs.ts";
import "./PeopleTiles.css"; import "./PeopleTiles.css";
/** /**
@ -43,6 +46,13 @@ import "./PeopleTiles.css";
* *
* Field names follow the API (is_owner, location_label), so a row * Field names follow the API (is_owner, location_label), so a row
* from /api/teams/:id/people drops in unchanged. * 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 { export interface Person {
@ -551,7 +561,14 @@ function Tile({
); );
if (!expandable) { if (!expandable) {
return <div className="pl__tile">{content}</div>; const href = profileHref(person);
return href ? (
<Link to={href} className="pl__tile pl__tile--link">
{content}
</Link>
) : (
<div className="pl__tile">{content}</div>
);
} }
return ( return (
@ -610,6 +627,7 @@ function DetailPanel({
const title = titleOf(person); const title = titleOf(person);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
const tint = person.accent || group?.accent; const tint = person.accent || group?.accent;
const profile = profileHref(person);
return ( return (
<div <div
@ -672,6 +690,12 @@ function DetailPanel({
{paragraph} {paragraph}
</p> </p>
))} ))}
{profile && (
<Link to={profile} className="pl__profile">
View full profile →
</Link>
)}
</div> </div>
); );
} }
@ -693,6 +717,12 @@ function Chevron() {
/* ── Helpers ─────────────────────────────────────────────────── */ /* ── 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 { function keyFor(group: PeopleGroup, person: Person, index: number): string {
return `${group.id}:${person.id ?? person.name ?? index}`; return `${group.id}:${person.id ?? person.name ?? index}`;
} }

View file

@ -276,3 +276,63 @@ export type AwardRecord = {
export const useAward = (id?: string): Resource<AwardRecord> => export const useAward = (id?: string): Resource<AwardRecord> =>
useRecord<AwardRecord>(detailPath('/awards', id), 'award') useRecord<AwardRecord>(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<PersonRecord> =>
useRecord<PersonRecord>(detailPath('/people', id), 'person')

363
src/pages/PersonDetail.tsx Normal file
View file

@ -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<string, string> = {
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 (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="person"
backTo="/leadership"
backLabel="Leadership"
/>
)
}
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: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts person={person} accent={accent} />
{[...person.bio, ...person.description].map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={person.blocks} accent={accent} />
</div>
<Contact person={person} accent={accent} />
</div>
),
},
]
if (person.roles.length > 0) {
sections.push({
id: 'roles',
title: 'Roles',
accent,
background: BACKGROUNDS[sections.length % 2],
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
{current.length > 0 && <RoleList roles={current} accent={accent} />}
{previous.length > 0 && (
<div>
{current.length > 0 && (
<h3 className="mb-4 text-lg font-semibold" style={{ color: accent }}>
Previously
</h3>
)}
<RoleList roles={previous} accent={accent} />
</div>
)}
</div>
),
})
}
if (person.events.length > 0) {
sections.push({
id: 'events',
title: 'Events',
accent,
background: BACKGROUNDS[sections.length % 2],
content: (
<div className="max-w-6xl mx-auto px-6">
<ul className="space-y-5">
{person.events.map((event) => (
<li key={event.id}>
<EventRow event={event} accent={accent} />
</li>
))}
</ul>
</div>
),
})
}
if (person.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Awards',
accent,
background: BACKGROUNDS[sections.length % 2],
content: (
<div className="max-w-6xl mx-auto px-6 space-y-6">
{person.awards.map((entry, index) => (
<div
key={`${entry.award.id}:${entry.awarded_on ?? index}`}
className="border-l-2 pl-5"
style={{ borderColor: accent }}
>
<p className="font-semibold" style={{ color: accent }}>
<Link to={awardHref(entry.award.id)} className="hover:underline">
{entry.award.name}
</Link>
</p>
{(entry.awarded_on || entry.event) && (
<p className="text-sm" style={{ color: BODY }}>
{year(entry.awarded_on)}
{entry.event && (
<>
{entry.awarded_on && ' · '}
<Link
to={eventHref(entry.event.id)}
className="hover:underline"
style={{ color: accent }}
>
{entry.event.title}
</Link>
</>
)}
</p>
)}
{entry.citation && (
<p className="mt-1 max-w-2xl italic leading-relaxed" style={{ color: BODY }}>
{entry.citation}
</p>
)}
</div>
))}
</div>
),
})
}
return (
<PageShell title={person.name} intro={person.tagline ?? undefined} sections={sections} />
)
}
/* ── The strip of facts under the heading ────────────────────── */
function Facts({ person, accent }: { person: PersonRecord; accent: string }) {
const photo = personPhoto(person.photo)
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
{photo ? (
<img
src={photo}
alt=""
loading="lazy"
decoding="async"
className="h-24 w-24 shrink-0 rounded-full object-cover"
/>
) : (
<span
aria-hidden="true"
className="flex h-24 w-24 shrink-0 items-center justify-center rounded-full text-2xl font-bold text-white"
style={{ background: accent }}
>
{initials(person.name)}
</span>
)}
{person.pronouns && <span style={{ color: BODY }}>{person.pronouns}</span>}
{person.org && (
<Link
to={orgHref(person.org.id, person.org.kind)}
className="font-medium hover:underline"
style={{ color: accent }}
>
{person.org.name}
</Link>
)}
{person.location_label && <span style={{ color: BODY }}>{person.location_label}</span>}
<Link to="/leadership" className="ml-auto hover:underline" style={{ color: accent }}>
Leadership
</Link>
</div>
)
}
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<typeof link> => Boolean(link))
if (person.links.length === 0 && outlined.length === 0) return null
return (
<div className="flex flex-wrap gap-3">
{person.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
{outlined.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: accent, color: accent }}
>
{link.label}
</a>
))}
</div>
)
}
/* ── Roles ───────────────────────────────────────────────────── */
function RoleList({ roles, accent }: { roles: PersonRole[]; accent: string }) {
return (
<ul className="grid gap-4 sm:grid-cols-2">
{roles.map((role, index) => (
<li
key={`${role.org.id}:${role.team?.id ?? ''}:${role.title ?? role.role}:${index}`}
className="rounded-xl border-l-4 bg-white px-5 py-3"
style={{ borderColor: accent }}
>
<p className="font-semibold" style={{ color: accent }}>
{role.title || ROLE_LABEL[role.role] || capitalize(role.role)}
</p>
<p className="text-sm" style={{ color: BODY }}>
{role.team && (
<>
<Link to={teamHref(role.team.id)} className="hover:underline">
{role.team.name}
</Link>
{' · '}
</>
)}
<Link to={orgHref(role.org.id, role.org.kind)} className="hover:underline">
{role.org.name}
</Link>
</p>
{tenure(role) && (
<p className="text-xs" style={{ color: BODY }}>
{tenure(role)}
</p>
)}
</li>
))}
</ul>
)
}
/* "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 (
<div className="border-l-2 pl-5" style={{ borderColor: accent }}>
<p className="font-semibold" style={{ color: accent }}>
<Link to={eventHref(event.id)} className="hover:underline">
{event.title}
</Link>
</p>
<p className="text-sm" style={{ color: BODY }}>
{[capacities, eventTypeLabel(event.event_type), when].filter(Boolean).join(' · ')}
</p>
</div>
)
}
/* 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
}