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

118
src/lib/adminSchema.d.ts vendored Normal file
View file

@ -0,0 +1,118 @@
/* Types for adminSchema.js: the client half of the descriptor-driven
admin CRUD engine. Rows are whatever columns the server-side
descriptor in server/src/admin-schema.js declares, so they stay a
string-keyed record; the descriptors are what's fixed. */
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
* ('region.scope') are side-table objects under their key, and
* child collections are arrays of rows under theirs. */
export type AdminRow = Record<string, unknown>;
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
* along on the lists that filterBy narrows. */
export type AdminOption = {
id: string;
label: string;
kind?: string;
org_id?: string | null;
};
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
export type AdminOptions = Record<string, AdminOption[]>;
export type FieldWidget =
| "text"
| "textarea"
| "select"
| "checkbox"
| "color"
| "number"
| "date";
/** A bare value, or [value, label]. */
export type SelectOption = string | readonly [string, string];
/** Show a group or collection only when another field has this value. */
export type FieldCondition = { path: string; value: unknown };
export type AdminFieldSpec = {
path: string;
label: string;
widget?: FieldWidget;
required?: boolean;
full?: boolean;
help?: string;
options?: readonly SelectOption[];
optionsFrom?: string;
blankLabel?: string;
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
/* Set by EntityEdit on the id field rather than in a manifest. */
readOnly?: boolean;
prefix?: string;
prefixPending?: boolean;
placeholder?: string;
};
export type FieldGroupSpec = {
legend: string;
note?: string;
when?: FieldCondition;
fields: AdminFieldSpec[];
};
export type CollectionSpec = {
key: string;
label: string;
addLabel?: string;
note?: string;
when?: FieldCondition;
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
blank: AdminRow;
fields: AdminFieldSpec[];
children?: CollectionSpec[];
};
export type ListColumn = {
key: string;
label: string;
primary?: boolean;
widget?: "bool";
};
export type ListFilter = {
key: string;
label: string;
options?: readonly SelectOption[];
optionsFrom?: string;
};
export type EntitySpec = {
key: string;
label: string;
singular: string;
idLabel: string;
/** "auto": the table assigns the id, so the form shows it rather than asking. */
idKind?: "auto";
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
slugFrom?: string | string[];
titleFrom?: string;
list: { columns: ListColumn[]; filters: ListFilter[] };
groups: FieldGroupSpec[];
children?: CollectionSpec[];
};
export type AdminEntityKey =
| "organizations"
| "events"
| "people"
| "teams"
| "awards"
| "timeline";
/* Indexed by route param as often as by name, so any other string
reads as possibly missing. */
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
readonly [key: string]: EntitySpec | undefined;
};
export declare function slugify(value: unknown): string;

View file

@ -14,12 +14,14 @@
import { createContext, useContext, useEffect } from "react";
export const AdminTitleContext = createContext(null);
export type AdminTitleValue = { setDetail: (value: string | null) => void };
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
/* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in
the tab. */
export function useAdminDetail(name) {
export function useAdminDetail(name: string | null | undefined) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {

26
src/lib/api.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
/* Types for api.js. The response type is the caller's to name:
every endpoint sends a different body, so get<T> defaults to
unknown rather than pretending to know. */
/** Per-field messages, as admin-crud.js and feedback.js send them. */
export type FieldErrors = Record<string, string>;
export declare class ApiError extends Error {
status?: number;
fields?: FieldErrors;
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
}
export declare function get<T = unknown>(
path: string,
options?: { ttl?: number; fallback?: T },
): Promise<T>;
export declare function invalidate(path?: string): void;
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
/* A 204 comes back as null. */
export declare function del<T = null>(path: string): Promise<T>;

View file

@ -13,21 +13,40 @@
staring at an empty table.
═══════════════════════════════════════════════════════════════ */
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
import type { Role } from "./roles.ts";
const AuthContext = createContext(null);
/* What currentUser() in server/src/auth.js returns, and what
/auth/me and /auth/login send under `user`. */
export type AdminUser = {
id: number;
email: string;
name: string | null;
role: Role;
};
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
type AuthResponse = { user: AdminUser };
type AuthValue = {
user: AdminUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<AdminUser>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
get("/auth/me", { ttl: 0 })
get<AuthResponse>("/auth/me", { ttl: 0 })
.then((data) => {
if (!ignore) setUser(data.user);
})
@ -43,8 +62,8 @@ export function AuthProvider({ children }) {
};
}, []);
const login = useCallback(async (email, password) => {
const data = await post("/auth/login", { email, password });
const login = useCallback(async (email: string, password: string) => {
const data = await post<AuthResponse>("/auth/login", { email, password });
setUser(data.user);
return data.user;
}, []);
@ -73,7 +92,7 @@ export function useAuth() {
/* Signals a session that ended while the page was open — the
admin pages call this when a request comes back 401. */
export function isUnauthorized(error) {
export function isUnauthorized(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type ComponentType, type ReactNode } from "react";
/* ═══════════════════════════════════════════════════════════════
SECTION MANIFEST
@ -33,25 +33,69 @@ import { useState } from "react";
want them.
═══════════════════════════════════════════════════════════════ */
export function useSectionManifest(manifest) {
/* What every section Component is handed on top of its own props. */
export type SectionInjected = { accent: string; view?: string };
export type SectionToggleProps = {
view: string;
setView: (value: string) => void;
accent: string;
options: string[];
};
export type SectionViews = {
options: string[];
default?: string;
Toggle: ComponentType<SectionToggleProps>;
};
export type SectionHeading = {
id: string;
title: string;
blurb?: string;
accent: string;
background: string;
};
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
Component: ComponentType<P & SectionInjected>;
/* The Component decides P; props are checked against it. */
props?: NoInfer<P>;
views?: SectionViews;
};
/* A manifest mixes components with different props, which no single
array element type can check. This checks each entry against its
own Component where it's written, then forgets P so the entries
fit one array. */
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
return entry as unknown as SectionEntry;
}
export type ManifestSection = SectionHeading & {
actions?: ReactNode;
content: ReactNode;
};
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
// One entry per section that has a toggle, seeded from its
// declared default so the control is right before anything loads.
const [views, setViews] = useState(() =>
const [views, setViews] = useState<Record<string, string | null>>(() =>
Object.fromEntries(
manifest
.filter(entry => entry.views)
.map(entry => [
entry.id,
entry.views.default ?? entry.views.options?.[0] ?? null,
entry.views?.default ?? entry.views?.options[0] ?? null,
]),
),
);
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
return manifest.map(entry => {
const { Component, props, views: spec, ...heading } = entry;
const view = views[entry.id];
const view = views[entry.id] ?? undefined;
const Toggle = spec?.Toggle;
return {
@ -59,7 +103,7 @@ export function useSectionManifest(manifest) {
actions: Toggle ? (
<Toggle
view={view}
view={view ?? ""}
setView={value => setView(entry.id, value)}
accent={entry.accent}
options={spec.options}

View file

@ -23,6 +23,8 @@
* and the public/ layout stay the frontend's business.
*/
import type { OrgKind } from './hrefs.ts'
export type DatePrecision = 'year' | 'month' | 'day'
/** What an entry is about. Drives the marker and the body layout. */
@ -41,6 +43,8 @@ export type TimelineRef = {
kind: RefKind
/** The row's TEXT primary key — an event id, org slug, team slug. */
id: string
/** Only on organizations: history.js copies v_timeline.org_kind. */
orgKind?: OrgKind
}
/** Filename plus the table it came from; the directory is derived

View file

@ -103,12 +103,22 @@ export type EventRecord = {
hosts: EventHost[]
description: string[]
links: Link[]
instagram?: Link | null
/** The instagram link's label, the handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[]
people: EventPerson[]
awards: EventAward[]
}
/** One row of GET /events: shapeEvent without the detail-only
* blocks, people and awards. */
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
/** An event_sections row, as GET /events sends it beside the list. */
export type EventSection = { id: string; name: string; sort_order: number }
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event')
@ -154,6 +164,17 @@ export type OrgEvent = {
color?: string | null
}
/** regions.scope's CHECK values. */
export type RegionScope = 'domestic' | 'international' | 'virtual'
/** A region_areas row as attachRegionDetails sends it. */
export type RegionArea = {
area_code: string
share: number
edge: 'top' | 'bottom' | null
note: string | null
}
export type OrganizationRecord = {
id: string
kind: 'national' | 'region' | 'chapter' | 'partner'
@ -173,14 +194,16 @@ export type OrganizationRecord = {
blocks: ContentBlock[]
links: Link[]
socials: Link[]
website?: Link | null
email?: Link | null
instagram?: Link | null
/* splitLinks in shape.js lifts these out as bare strings: the
website's url, the email's label, the instagram handle. */
website?: string | null
email?: string | null
instagram?: string | null
/** Shape depends on `kind`; empty object for national and partner. */
details: {
scope?: string | null
scope?: RegionScope | null
map_note?: string | null
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
areas?: RegionArea[]
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
region_id?: string | null
region_name?: string | null
@ -194,6 +217,14 @@ export type OrganizationRecord = {
events: OrgEvent[]
}
/** One row of GET /organizations: the card surface and details,
* without the sections only the org's own page loads. */
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
sort_order: number
}
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
@ -211,7 +242,8 @@ export type TeamRecord = {
description: string[]
links: Link[]
socials: Link[]
instagram?: Link | null
/** The instagram handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[]
}

View file

@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
setError(null)
setNotFound(false)
try {
const body = await get(path as string)
const body = await get<Record<string, unknown> | null>(path as string)
if (!live) return
// A 200 with the key absent is a server-side shaping bug,
// not an empty record. Say so rather than rendering a page