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:
parent
5286cae9ca
commit
2428f4412a
39 changed files with 1318 additions and 343 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue