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>
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
/**
|
|
* useRecord — one record from one endpoint, on the useHistory
|
|
* pattern.
|
|
*
|
|
* Named for what it returns, and deliberately not useResource:
|
|
* src/lib/useResource.js is a different hook — it hands back the
|
|
* whole response body and takes an options object — and a .ts file
|
|
* of the same name would sit one extension away from it. An import
|
|
* whose target goes missing then resolves to the other file without
|
|
* a word, and every detail page renders the wrapper object instead
|
|
* of the record. That is exactly how this file came to exist.
|
|
*
|
|
* Every detail route in content.js answers the same shape: 200 with
|
|
* a single top-level key, or 404 with `{ error }`. So the hook takes
|
|
* the path and the key, and the four callers in useContent.ts are
|
|
* one line each rather than four copies of this file.
|
|
*
|
|
* `key` is a string rather than a selector function on purpose. A
|
|
* selector passed inline would be a new identity every render, and
|
|
* putting it in the effect's deps would refetch forever; leaving it
|
|
* out would silently use a stale closure. A string has neither
|
|
* problem.
|
|
*
|
|
* Carried over from useHistory, and worth restating:
|
|
*
|
|
* · `reload` invalidates before it refetches. Without that, the
|
|
* retry button inside the 60s TTL hands back the same settled
|
|
* promise and looks like it did nothing. A *failed* request is
|
|
* already evicted by api.js, so this matters for the refresh
|
|
* case rather than the error case.
|
|
*
|
|
* · no AbortController. `get` shares one promise between callers,
|
|
* so aborting on unmount would cancel someone else's request.
|
|
* The `live` flag drops the result instead.
|
|
*
|
|
* · no `fallback`. Rendering plausible-looking content with no
|
|
* sign the server is down is worse than a visible error.
|
|
*
|
|
* `notFound` is separated from `error` because they are different
|
|
* pages: a 404 is a slug that doesn't exist and retrying won't help,
|
|
* anything else is worth a Try again button.
|
|
*
|
|
* A null `path` means there is nothing to ask for, and the hook
|
|
* reports notFound rather than loading forever. Callers build the
|
|
* path with detailPath() from hrefs.ts, which returns null for an
|
|
* id that could never be real — "undefined" chief among them.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { get, invalidate, ApiError } from './api.js'
|
|
|
|
export type Resource<T> = {
|
|
data: T | null
|
|
loading: boolean
|
|
error: string | null
|
|
notFound: boolean
|
|
reload: () => void
|
|
}
|
|
|
|
export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
|
const [data, setData] = useState<T | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [notFound, setNotFound] = useState(false)
|
|
const [attempt, setAttempt] = useState(0)
|
|
|
|
const reload = useCallback(() => {
|
|
if (path) invalidate(path)
|
|
setAttempt((n) => n + 1)
|
|
}, [path])
|
|
|
|
useEffect(() => {
|
|
// Nothing to ask for. Distinguished from "not asked yet" by the
|
|
// caller: useContent passes null only when the id is unusable,
|
|
// and an unusable id is a 404 as far as the visitor is
|
|
// concerned.
|
|
if (!path) {
|
|
setData(null)
|
|
setError(null)
|
|
setNotFound(true)
|
|
setLoading(false)
|
|
return undefined
|
|
}
|
|
|
|
let live = true
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError(null)
|
|
setNotFound(false)
|
|
try {
|
|
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
|
|
// full of blanks.
|
|
const record = body?.[key]
|
|
if (record === undefined) {
|
|
setError(`The server sent no "${key}".`)
|
|
setData(null)
|
|
return
|
|
}
|
|
setData(record as T)
|
|
} catch (err) {
|
|
if (!live) return
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
setNotFound(true)
|
|
setData(null)
|
|
return
|
|
}
|
|
setError(
|
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
|
)
|
|
setData(null)
|
|
} finally {
|
|
if (live) setLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
return () => {
|
|
live = false
|
|
}
|
|
}, [path, key, attempt])
|
|
|
|
return { data, loading, error, notFound, reload }
|
|
}
|