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 /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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/organizations/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/teams/:id" element={<TeamDetail />} />
|
||||
<Route path="/awards/:id" element={<AwardDetail />} />
|
||||
<Route path="/people/:id" element={<PersonDetail />} />
|
||||
<Route path="leadership" element={<Leadership />} />
|
||||
<Route path="resources" element={<Resources />} />
|
||||
<Route path="history" element={<History />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <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 (
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -672,6 +690,12 @@ function DetailPanel({
|
|||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{profile && (
|
||||
<Link to={profile} className="pl__profile">
|
||||
View full profile →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,3 +276,63 @@ export type AwardRecord = {
|
|||
|
||||
export const useAward = (id?: string): Resource<AwardRecord> =>
|
||||
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