Type src/ against API and database shapes, fixing implicit anys

Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-25 04:13:46 -05:00
parent 5286cae9ca
commit 2428f4412a
39 changed files with 1318 additions and 343 deletions

View file

@ -4,7 +4,15 @@ import { Link } from "react-router-dom";
ARROW LINK
═══════════════════════════════════════════════════════════════ */
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
type ArrowLinkProps = {
to: string;
label: string;
color: string;
/** Tailwind size classes for the circle. */
size?: string;
};
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) {
return (
<Link
to={to}

View file

@ -117,7 +117,7 @@ export default function Layout() {
const targets = sections
.map((s) => document.querySelector(s.hash))
.filter(Boolean);
.filter((el): el is Element => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(

View file

@ -29,9 +29,27 @@
/>
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
const TEAL = "#138ba0";
export function Section({ section }) {
export type ShellSection = {
id: string;
title: string;
blurb?: string;
accent: string;
background: string;
actions?: ReactNode;
content: ReactNode;
};
type PageShellProps = {
title: ReactNode;
intro?: ReactNode;
sections: ShellSection[];
};
export function Section({ section }: { section: ShellSection }) {
const {
id,
title,
@ -70,7 +88,7 @@ export function Section({ section }) {
);
}
export default function PageShell({ title, intro, sections }) {
export default function PageShell({ title, intro, sections }: PageShellProps) {
return (
<>
{/* Page header */}

View file

@ -164,7 +164,7 @@ export default function PeopleTiles({
Promise.all(
specs.map((spec) =>
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
get<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
spec,
data,
})),
@ -198,8 +198,8 @@ export default function PeopleTiles({
let live = true;
setFailed(false);
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data: { people: Person[] }) => {
get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data) => {
if (!live) return;
const byId: Record<string, Person> = {};
for (const person of data.people) byId[String(person.id)] = person;
@ -304,11 +304,10 @@ function resolveAll(
}
const { peopleslug, ...overrides } = entry;
const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
}
resolved.push(merged);
const defined: Partial<Person> = Object.fromEntries(
Object.entries(overrides).filter(([, value]) => value !== undefined),
);
resolved.push({ ...base, ...defined });
}
return resolved;

View file

@ -32,7 +32,16 @@
able to read and copy.
═══════════════════════════════════════════════════════════════ */
import { useRef, useState } from "react";
import { useRef, useState, type ChangeEvent, type ReactNode } from "react";
import type { FieldErrors } from "../../lib/api.js";
import type {
AdminFieldSpec,
AdminOption,
AdminOptions,
AdminRow,
CollectionSpec,
} from "../../lib/adminSchema.js";
const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
@ -56,37 +65,56 @@ const inputLocked =
/* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) {
export function getPath(object: unknown, path: string | null | undefined): unknown {
// An entity with no slug has no heading path either, and a missing
// path should read as "no value" rather than throwing on .split.
if (!path) return undefined;
return path.split(".").reduce((value, key) => value?.[key], object);
return path
.split(".")
.reduce<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
}
export function setPath(object, path, value) {
export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
const inner = (object?.[head] ?? {}) as AdminRow;
return { ...object, [head]: setPath(inner, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) {
/* [value, label, the option row it came from]. Manifest options
have no row, which is what filterBy's `!raw` lets through. */
type Choice = [id: string, label: string, raw?: AdminOption];
type FieldProps = {
field: AdminFieldSpec;
/* Whatever the row holds at field.path; shown as text. */
value: unknown;
row?: AdminRow;
options?: AdminOptions | null;
error?: string;
onChange: (value: string | number) => void;
};
export function Field({ field, value, row, options, error, onChange }: FieldProps) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
const text = value == null ? "" : String(value);
let list = null;
let list: Choice[] = [];
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
: (field.options ?? []).map((o) =>
Array.isArray(o) ? [o[0], o[1]] : [o, o],
? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o])
: (field.options ?? []).map((o): Choice =>
typeof o === "string" ? [o, o] : [o[0], o[1]],
);
if (field.filterBy && row) {
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
const { filterBy } = field;
if (filterBy && row) {
list = list.filter(([, , raw]) => !raw || filterBy(raw, row));
}
// A stored value with no matching option renders as the blank
@ -102,8 +130,9 @@ export function Field({ field, value, row, options, error, onChange }) {
const common = {
id,
className: `${input} ${error ? inputError : ""}`,
value: value ?? "",
onChange: (e) => onChange(e.target.value),
value: text,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
onChange(e.target.value),
};
return (
@ -145,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) {
}`}
>
<option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={value}>{value} — no longer exists</option>}
{orphaned && <option value={text}>{text} — no longer exists</option>}
{list.map(([id2, label]) => (
<option key={id2} value={id2}>
{label}
@ -156,7 +185,7 @@ export function Field({ field, value, row, options, error, onChange }) {
<div className="flex gap-2">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#138ba0"}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
@ -188,7 +217,7 @@ export function Field({ field, value, row, options, error, onChange }) {
<input
id={id}
type="text"
value={value ?? ""}
value={text}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
@ -222,26 +251,36 @@ export function Field({ field, value, row, options, error, onChange }) {
);
}
export function FieldGrid({ children }) {
export function FieldGrid({ children }: { children: ReactNode }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
type RepeaterProps = {
spec: CollectionSpec;
rows: AdminRow[] | null | undefined;
options?: AdminOptions | null;
errors?: Partial<FieldErrors> | null;
/** Where this collection's rows sit in the server's error keys. */
errorPrefix: string;
onChange: (rows: AdminRow[]) => void;
};
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
const list = rows ?? [];
// Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const rowRefs = useRef([]);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const update = (index, next) =>
const update = (index: number, next: AdminRow) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index, delta) => {
const move = (index: number, delta: number) => {
const target = index + delta;
if (target < 0 || target >= list.length) return;
const next = [...list];
@ -249,7 +288,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
onChange(next);
};
const relocate = (from, to) => {
const relocate = (from: number | null, to: number | null) => {
if (from === to || from == null || to == null) return;
const next = [...list];
const [moved] = next.splice(from, 1);
@ -382,7 +421,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater
spec={nested}
rows={row[nested.key]}
rows={row[nested.key] as AdminRow[] | undefined}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
@ -398,7 +437,15 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
);
}
function IconButton({ label, onClick, danger, disabled, children }) {
type IconButtonProps = {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
children: ReactNode;
};
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
return (
<button
type="button"