Merge pull request 'Add person detail page and link people tiles to it' (#6) from feature/person-detail into main
Reviewed-on: #6
This commit is contained in:
commit
b4b013209b
6 changed files with 643 additions and 6 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
363
src/pages/PersonDetail.tsx
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue