v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

View 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;
}