diff --git a/server/src/routes/people.js b/server/src/routes/people.js
index e89ebca..dbc31d9 100644
--- a/server/src/routes/people.js
+++ b/server/src/routes/people.js
@@ -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;
diff --git a/src/App.tsx b/src/App.tsx
index 9b7efd4..ff85789 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css
index 2f70d5d..ecff0d2 100644
--- a/src/components/PeopleTiles.css
+++ b/src/components/PeopleTiles.css
@@ -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;
diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx
index bd738a9..62dcf4f 100644
--- a/src/components/PeopleTiles.tsx
+++ b/src/components/PeopleTiles.tsx
@@ -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
+ )
+}
+
+/* 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
+}