v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
30
src/components/ArrowLink.tsx
Normal file
30
src/components/ArrowLink.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Link } from "react-router-dom";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ARROW LINK
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`${size} shrink-0 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110`}
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SITE_BANNER } from "../bannerConfig.js";
|
||||
import { SITE_BANNER } from "../data/bannerConfig.js";
|
||||
|
||||
export default function Banner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
|
|
|||
365
src/components/PeopleTiles.css
Normal file
365
src/components/PeopleTiles.css
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/* PeopleTiles — polaroid-style people list.
|
||||
Everything is scoped under .pl and driven by custom properties, so a section
|
||||
can retune it inline: <PeopleTiles style={{ "--pl-base-w": "150px" }} /> */
|
||||
|
||||
.pl {
|
||||
/* geometry */
|
||||
--pl-scale: 1;
|
||||
--pl-base-w: 132px;
|
||||
--pl-w: calc(var(--pl-base-w) * var(--pl-scale));
|
||||
--pl-gap: calc(0.875rem * var(--pl-scale));
|
||||
--pl-pad: calc(0.5rem * var(--pl-scale));
|
||||
--pl-photo-ratio: 1;
|
||||
--pl-radius: 3px;
|
||||
|
||||
/* color */
|
||||
--pl-accent: #138ba0;
|
||||
--pl-frame-bg: #fff;
|
||||
--pl-frame-edge: rgba(15, 23, 42, 0.1);
|
||||
--pl-photo-bg: #e7edef;
|
||||
--pl-ink: #16262b;
|
||||
--pl-muted: #4a6b72;
|
||||
--pl-rule: rgba(45, 200, 224, 0.3);
|
||||
--pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5);
|
||||
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-gap) * 1.25);
|
||||
color: var(--pl-ink);
|
||||
}
|
||||
|
||||
.pl[data-size="sm"] {
|
||||
--pl-base-w: 104px;
|
||||
--pl-radius: 2px;
|
||||
}
|
||||
|
||||
.pl[data-size="lg"] {
|
||||
--pl-base-w: 164px;
|
||||
--pl-photo-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
/* groups --------------------------------------------------------------- */
|
||||
|
||||
.pl__groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: calc(var(--pl-gap) * 1.5);
|
||||
}
|
||||
|
||||
.pl[data-align="center"] .pl__groups,
|
||||
.pl[data-align="center"] .pl__row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pl__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-gap) * 0.75);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl__group + .pl__group {
|
||||
padding-left: calc(var(--pl-gap) * 1.5);
|
||||
border-left: 1px solid var(--pl-rule);
|
||||
}
|
||||
|
||||
.pl__group-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pl__group-label {
|
||||
margin: 0;
|
||||
font-size: calc(0.9375rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.pl__group-note {
|
||||
margin: 0;
|
||||
font-size: calc(0.8125rem * var(--pl-scale));
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--pl-gap);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pl[data-overflow="scroll"] .pl__row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 0.35rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.pl[data-overflow="scroll"] .pl__item {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.pl__item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* tile ----------------------------------------------------------------- */
|
||||
|
||||
.pl__tile {
|
||||
display: block;
|
||||
width: var(--pl-w);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.pl__tile--button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pl__frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--pl-pad) * 0.9);
|
||||
padding: var(--pl-pad);
|
||||
padding-bottom: calc(var(--pl-pad) * 1.6);
|
||||
background: var(--pl-frame-bg);
|
||||
border: 1px solid var(--pl-frame-edge);
|
||||
border-radius: var(--pl-radius);
|
||||
box-shadow: var(--pl-shadow);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame {
|
||||
transform: rotate(-1.1deg);
|
||||
}
|
||||
|
||||
.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame {
|
||||
transform: rotate(0.9deg);
|
||||
}
|
||||
|
||||
.pl__tile--button:hover .pl__frame,
|
||||
.pl__tile--button: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 {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pl__tile--button:focus-visible .pl__frame {
|
||||
outline: 2px solid var(--pl-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.pl__tile[aria-expanded="true"] .pl__frame {
|
||||
border-color: var(--pl-accent);
|
||||
box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.pl__photo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: var(--pl-photo-ratio);
|
||||
overflow: hidden;
|
||||
background: var(--pl-photo-bg);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.pl__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pl__initials {
|
||||
font-size: calc(1.25rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0 calc(var(--pl-pad) * 0.25);
|
||||
min-height: calc(1.1rem * var(--pl-scale));
|
||||
}
|
||||
|
||||
.pl__name {
|
||||
font-size: calc(0.875rem * var(--pl-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.pl__title {
|
||||
font-size: calc(0.75rem * var(--pl-scale));
|
||||
line-height: 1.3;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__badge {
|
||||
position: absolute;
|
||||
right: calc(var(--pl-pad) * 0.6);
|
||||
bottom: calc(var(--pl-pad) * 0.6);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(1.25rem * var(--pl-scale));
|
||||
height: calc(1.25rem * var(--pl-scale));
|
||||
border-radius: 999px;
|
||||
background: var(--pl-accent);
|
||||
color: #fff;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.pl__tile[aria-expanded="true"] .pl__badge {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* bio panel ------------------------------------------------------------ */
|
||||
|
||||
.pl__bio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-left: 3px solid var(--pl-accent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--pl-accent) 7%, #fff);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.pl__bio-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pl__bio-name {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pl__bio-title {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__close {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 1px solid var(--pl-frame-edge);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--pl-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pl__close:hover {
|
||||
color: var(--pl-ink);
|
||||
border-color: var(--pl-accent);
|
||||
}
|
||||
|
||||
.pl__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pl__fact dt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__fact dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pl__fact a {
|
||||
color: var(--pl-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.pl__bio-text {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.pl__empty {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--pl-muted);
|
||||
}
|
||||
|
||||
.pl__sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* narrow layouts ------------------------------------------------------- */
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.pl__group + .pl__group {
|
||||
padding-left: 0;
|
||||
padding-top: calc(var(--pl-gap) * 1.1);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--pl-rule);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (container-type: inline-size) {
|
||||
@media (max-width: 640px) {
|
||||
.pl__group + .pl__group {
|
||||
padding-left: 0;
|
||||
padding-top: calc(var(--pl-gap) * 1.1);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--pl-rule);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pl__frame,
|
||||
.pl__badge {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pl__tile--button:hover .pl__frame,
|
||||
.pl__tile--button:focus-visible .pl__frame {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
375
src/components/PeopleTiles.tsx
Normal file
375
src/components/PeopleTiles.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import "./PeopleTiles.css";
|
||||
|
||||
/**
|
||||
* PeopleTiles — a horizontal, polaroid-style people list.
|
||||
*
|
||||
* Drop into any section:
|
||||
* <PeopleTiles size="md" groups={staffGroups} />
|
||||
* <PeopleTiles size="sm" people={volunteers} />
|
||||
*
|
||||
* Sizes
|
||||
* sm photo + name
|
||||
* md photo + name + title
|
||||
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
|
||||
*
|
||||
* Group shape
|
||||
* { id, label?, note?, accent?, people: [person] }
|
||||
*
|
||||
* Person shape
|
||||
* {
|
||||
* id, name,
|
||||
* title?, // "Regional Director"
|
||||
* photo?, // "/people/jane-doe.jpg" — falls back to initials
|
||||
* pronouns?, // "she/her"
|
||||
* age?, // number, or use birthdate
|
||||
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
|
||||
* org?, // "Grace Chapel" or { name, href }
|
||||
* bio?, // string or string[] (paragraphs)
|
||||
* accent?, // per-person override
|
||||
* }
|
||||
*/
|
||||
|
||||
const SIZE_FEATURES = {
|
||||
sm: { title: false, bio: false },
|
||||
md: { title: true, bio: false },
|
||||
lg: { title: true, bio: true },
|
||||
};
|
||||
|
||||
export default function PeopleTiles({
|
||||
people,
|
||||
groups,
|
||||
size = "md",
|
||||
scale = 1,
|
||||
overflow = "wrap", // "wrap" | "scroll"
|
||||
align = "start", // "start" | "center"
|
||||
accent,
|
||||
tilt = false,
|
||||
emptyMessage = "No one listed yet.",
|
||||
onExpand,
|
||||
className = "",
|
||||
style,
|
||||
...rest
|
||||
}) {
|
||||
const baseId = useId().replace(/:/g, "");
|
||||
const [openKey, setOpenKey] = useState(null);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
|
||||
const features = SIZE_FEATURES[resolvedSize];
|
||||
|
||||
const resolvedGroups = useMemo(() => {
|
||||
const source = Array.isArray(groups) && groups.length
|
||||
? groups
|
||||
: Array.isArray(people) && people.length
|
||||
? [{ id: "all", people }]
|
||||
: [];
|
||||
|
||||
return source
|
||||
.map((group, groupIndex) => ({
|
||||
...group,
|
||||
id: group.id ?? `group-${groupIndex}`,
|
||||
people: (group.people || []).filter(Boolean),
|
||||
}))
|
||||
.filter((group) => group.people.length > 0);
|
||||
}, [groups, people]);
|
||||
|
||||
// Close the bio if the person it belongs to disappears from the data.
|
||||
useEffect(() => {
|
||||
if (!openKey) return;
|
||||
const stillThere = resolvedGroups.some((group) =>
|
||||
group.people.some((person, index) => keyFor(group, person, index) === openKey),
|
||||
);
|
||||
if (!stillThere) setOpenKey(null);
|
||||
}, [openKey, resolvedGroups]);
|
||||
|
||||
if (!resolvedGroups.length) {
|
||||
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
|
||||
}
|
||||
|
||||
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
|
||||
|
||||
function toggle(group, person, index) {
|
||||
const key = keyFor(group, person, index);
|
||||
const next = openKey === key ? null : key;
|
||||
setOpenKey(next);
|
||||
if (onExpand) onExpand(next ? person : null, next ? group : null);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape" && openKey) {
|
||||
event.stopPropagation();
|
||||
setOpenKey(null);
|
||||
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
|
||||
if (button) button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`pl ${className}`.trim()}
|
||||
data-size={resolvedSize}
|
||||
data-overflow={overflow}
|
||||
data-align={align}
|
||||
data-tilt={tilt ? "on" : "off"}
|
||||
style={{
|
||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||
...(accent ? { "--pl-accent": accent } : null),
|
||||
...style,
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
<div className="pl__groups">
|
||||
{resolvedGroups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
className="pl__group"
|
||||
style={group.accent ? { "--pl-accent": group.accent } : undefined}
|
||||
aria-label={group.label || undefined}
|
||||
>
|
||||
{(group.label || group.note) && (
|
||||
<header className="pl__group-head">
|
||||
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
|
||||
{group.note && <p className="pl__group-note">{group.note}</p>}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<ul className="pl__row">
|
||||
{group.people.map((person, index) => {
|
||||
const key = keyFor(group, person, index);
|
||||
const expandable = features.bio && hasBio(person);
|
||||
const isOpen = expandable && openKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="pl__item"
|
||||
style={person.accent ? { "--pl-accent": person.accent } : undefined}
|
||||
>
|
||||
<Tile
|
||||
person={person}
|
||||
showTitle={features.title}
|
||||
expandable={expandable}
|
||||
isOpen={isOpen}
|
||||
panelId={`${baseId}-bio`}
|
||||
onToggle={() => toggle(group, person, index)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<BioPanel
|
||||
id={`${baseId}-bio`}
|
||||
person={open.person}
|
||||
group={open.group}
|
||||
onClose={() => {
|
||||
setOpenKey(null);
|
||||
if (onExpand) onExpand(null, null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="pl__frame">
|
||||
<span className="pl__photo">
|
||||
<Photo src={person.photo} name={person.name} />
|
||||
</span>
|
||||
<span className="pl__caption">
|
||||
<span className="pl__name">{person.name}</span>
|
||||
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
|
||||
</span>
|
||||
{expandable && (
|
||||
<span className="pl__badge" aria-hidden="true">
|
||||
<Chevron />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!expandable) {
|
||||
return <div className="pl__tile">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pl__tile pl__tile--button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{content}
|
||||
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ src, name }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<span className="pl__initials" aria-hidden="true">
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="pl__img"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BioPanel({ id, person, group, onClose }) {
|
||||
const age = resolveAge(person);
|
||||
const org = resolveOrg(person.org);
|
||||
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="pl__bio"
|
||||
role="region"
|
||||
aria-label={`About ${person.name}`}
|
||||
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
|
||||
>
|
||||
<div className="pl__bio-head">
|
||||
<div>
|
||||
<p className="pl__bio-name">{person.name}</p>
|
||||
{person.title && <p className="pl__bio-title">{person.title}</p>}
|
||||
</div>
|
||||
<button type="button" className="pl__close" onClick={onClose}>
|
||||
<span className="pl__sr">Close bio</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(person.pronouns || age != null || org) && (
|
||||
<dl className="pl__facts">
|
||||
{person.pronouns && (
|
||||
<div className="pl__fact">
|
||||
<dt>Pronouns</dt>
|
||||
<dd>{person.pronouns}</dd>
|
||||
</div>
|
||||
)}
|
||||
{age != null && (
|
||||
<div className="pl__fact">
|
||||
<dt>Age</dt>
|
||||
<dd>{age}</dd>
|
||||
</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="pl__fact">
|
||||
<dt>Home organization</dt>
|
||||
<dd>
|
||||
{org.href ? (
|
||||
<a href={org.href} target="_blank" rel="noreferrer">
|
||||
{org.name}
|
||||
</a>
|
||||
) : (
|
||||
org.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
||||
<p key={index} className="pl__bio-text">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
|
||||
<path
|
||||
d="M4 6.5 8 10.5 12 6.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* helpers ------------------------------------------------------------- */
|
||||
|
||||
function keyFor(group, person, index) {
|
||||
return `${group.id}:${person.id ?? person.name ?? index}`;
|
||||
}
|
||||
|
||||
function findByKey(groups, key) {
|
||||
if (!key) return null;
|
||||
for (const group of groups) {
|
||||
for (let index = 0; index < group.people.length; index += 1) {
|
||||
const person = group.people[index];
|
||||
if (keyFor(group, person, index) === key) return { group, person };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBio(person) {
|
||||
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
|
||||
return Boolean(person.bio);
|
||||
}
|
||||
|
||||
function initials(name = "") {
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0] || "")
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function resolveAge(person) {
|
||||
if (typeof person.age === "number") return person.age;
|
||||
if (!person.birthdate) return null;
|
||||
const born = new Date(person.birthdate);
|
||||
if (Number.isNaN(born.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - born.getFullYear();
|
||||
const monthDelta = now.getMonth() - born.getMonth();
|
||||
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
function resolveOrg(org) {
|
||||
if (!org) return null;
|
||||
if (typeof org === "string") return { name: org };
|
||||
if (!org.name) return null;
|
||||
return org;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue