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