Files
fofos-admin/web/usePages.ts
T
pastilhasandClaude Opus 5 5b1c5a2590 the URL says which page, not which row
`?page=about` instead of `?page=2`. A URL that names the page can be read, sent to somebody, and still
means the right thing after a database is restored from a dump with different ids — which happened
once in this project already, earlier today.

Home needs a word, because its slug is empty by design: that is the check constraint, and it is what
makes `/` fall out of joining a slug to the root. An empty string is no use in a query parameter that
has to name a page unambiguously, so the admin says `home` — and the site's API now REFUSES that slug,
which is what makes the mapping total in both directions rather than merely usual.

One reserved address costs nothing. Anybody who wanted /home wanted the front page, which they have.

One place maps a page to its URL value and back — pageParam and pageFromParam — so the nav, the detail
panel and the builder cannot disagree about what `?page=` means.

`record` is deleted on every navigation. It belonged to the per-page record panels, which are
unreachable now; nothing reads it, so it was not a bug, but every URL anybody copied carried a
parameter naming a row in a section that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:42:16 +01:00

63 lines
2.5 KiB
TypeScript

import { useCallback, useRef } from 'react';
import { defineChannel } from 'hooks/usePanelChannel';
/** A page, as the site's admin API describes it. */
export type Page = {
id: number;
slug: string;
/** `/` for Home, `/about` for the rest — built by the server so nothing here reasons about slugs. */
path: string;
title: string;
menu_label: string | null;
position: number;
is_home: boolean;
};
/**
* The page list, shared between the two panels.
*
* The nav draws the pages as rows under Pages, and the editor owns them — and they are SIBLINGS in a
* workspace, so neither can pass the other a prop. Both fetching would give two lists that agree until
* somebody renames a page, at which point the nav keeps the old name until something reloads it.
*
* So the editor announces what it has and the nav listens. A channel is the right tool for this and
* the wrong one for SELECTION: which page is open lives in the URL, per docs/navigation-audit.md.
* This carries data, not state.
*
* Null means nobody has said yet — the nav then shows no children rather than an empty list, which
* would read as "this site has no pages".
*/
const usePagesList = defineChannel<Page[] | null>('fofos-admin:pages', null);
export const usePages = () => {
const [pages, setPages] = usePagesList();
// The setter is a fresh closure every render and `announce` goes into dependency lists, so it is
// read through a ref — stable identity without freezing the first render's setter. Same reasoning
// as channel.ts.
const setRef = useRef(setPages);
setRef.current = setPages;
const announce = useCallback((rows: Page[] | null) => setRef.current(rows), []);
return { pages, announce };
};
/**
* The value `?page=` carries, and how to read it back.
*
* The SLUG rather than the id, so a URL says which page it is: `?page=about` can be read, sent to
* somebody, and still means the right thing after a database is restored from a dump with different
* ids — which happened once already in this project.
*
* Home is the exception and needs a word, because its slug is empty by design. `home` is reserved by
* the site's API, so no page can take that address and the mapping is total in both directions.
*/
export const HOME_PARAM = 'home';
export const pageParam = (page: Page): string => page.slug || HOME_PARAM;
export const pageFromParam = (pages: Page[] | null, value: string | null): Page | null => {
if (!pages || !value) return null;
return pages.find((page) => pageParam(page) === value) ?? null;
};