From 2428f4412acb61748f916a4b611f2e39ca49a2a5 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 04:13:46 -0500 Subject: [PATCH] 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 --- src/components/ArrowLink.tsx | 10 +- src/components/Layout.tsx | 2 +- src/components/PageShell.tsx | 22 +- src/components/PeopleTiles.tsx | 15 +- src/components/admin/fields.tsx | 101 +++++-- src/data/bannerConfig.d.ts | 10 + src/data/chapters.d.ts | 47 ++++ src/data/eventData.d.ts | 27 ++ src/data/feedbackTypes.d.ts | 7 + src/data/mapGrid.d.ts | 51 ++++ src/data/organizations.d.ts | 21 ++ src/lib/adminSchema.d.ts | 118 +++++++++ src/lib/adminTitle.tsx | 6 +- src/lib/api.d.ts | 26 ++ src/lib/auth.tsx | 35 ++- src/lib/sections.tsx | 58 +++- src/lib/timeline.ts | 4 + src/lib/useContent.ts | 46 +++- src/lib/useRecord.ts | 2 +- src/navConfig.d.ts | 20 ++ src/pages/Community.tsx | 14 +- src/pages/EventDetail.tsx | 4 +- src/pages/Home.tsx | 12 +- src/pages/OrganizationDetail.tsx | 8 +- src/pages/Retreats.tsx | 28 +- src/pages/admin/AdminFeedback.tsx | 75 ++++-- src/pages/admin/AdminHome.tsx | 19 +- src/pages/admin/AdminLayout.tsx | 4 +- src/pages/admin/AdminLogin.tsx | 6 +- src/pages/admin/AdminPanel.tsx | 95 +++++-- src/pages/admin/EntityEdit.tsx | 84 +++--- src/pages/admin/EntityList.tsx | 35 ++- src/pages/admin/RequireRole.tsx | 4 +- src/pages/admin/adminNav.d.ts | 45 ++++ src/pages/sections/EventList-Cards.tsx | 88 ++++-- src/pages/sections/FeedbackForm.tsx | 48 +++- src/pages/sections/OrgList-Card.tsx | 58 +++- src/pages/sections/OrgList-Map.tsx | 339 ++++++++++++++++-------- src/pages/sections/OrgList-Vertical.tsx | 67 ++++- 39 files changed, 1318 insertions(+), 343 deletions(-) create mode 100644 src/data/bannerConfig.d.ts create mode 100644 src/data/chapters.d.ts create mode 100644 src/data/eventData.d.ts create mode 100644 src/data/feedbackTypes.d.ts create mode 100644 src/data/mapGrid.d.ts create mode 100644 src/data/organizations.d.ts create mode 100644 src/lib/adminSchema.d.ts create mode 100644 src/lib/api.d.ts create mode 100644 src/navConfig.d.ts create mode 100644 src/pages/admin/adminNav.d.ts diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index b2f9af0..056fd49 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -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 ( document.querySelector(s.hash)) - .filter(Boolean); + .filter((el): el is Element => el !== null); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 63ce434..496f926 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -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 */} diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index 61fd716..bd738a9 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -164,7 +164,7 @@ export default function PeopleTiles({ Promise.all( specs.map((spec) => - get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({ + get(`/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 = {}; 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)[key] = value; - } - resolved.push(merged); + const defined: Partial = Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined), + ); + resolved.push({ ...base, ...defined }); } return resolved; diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index 95296f8..c56f092 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -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((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) => + onChange(e.target.value), }; return ( @@ -145,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) { }`} > - {orphaned && } + {orphaned && } {list.map(([id2, label]) => (