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,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>
);
}

View file

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

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

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