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

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

View file

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