From 5b1c5a2590c1807942f302cb191fb814d4ced705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 04:42:16 +0100 Subject: [PATCH] the URL says which page, not which row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?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) --- web/DetailPanel.tsx | 5 +++-- web/NavPanel.tsx | 17 +++++++++++++---- web/sections/PageBuilderPanel.tsx | 6 +++--- web/usePages.ts | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 51661c6..526691f 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -153,12 +153,13 @@ export const DetailPanel = () => { const [params] = useSearchParams(); const raw = params.get('section'); /* - A page selected inside Pages opens the BUILDER, not the list with a row lit up. + A page selected inside Pages opens the BUILDER, not the list with a row lit up. By SLUG, so the URL + says which page rather than which row — see pageParam in usePages. The nav says the same thing — Pages is a section and the pages are rows under it — so the detail panel follows it. `?page=` is what distinguishes the two, which keeps both addressable. */ - const selectedPage = Number(params.get('page')) || null; + const selectedPage = params.get('page'); const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const { ready, rootSet, loading } = useReady(); diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 84d0a04..a42942e 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -2,7 +2,7 @@ import { Link, useSearchParams } from 'react-router'; import { Lock } from 'lucide-react'; import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections'; import { useReady } from './useReady'; -import { usePages } from './usePages'; +import { pageParam, usePages } from './usePages'; /** * The left column: which part of the fofos site the detail panel is showing. @@ -53,7 +53,7 @@ export const NavPanel = () => { // Announced by PagesPanel rather than fetched here — see usePages. Null until it has spoken, which // is why nothing is drawn instead of an empty list. const { pages } = usePages(); - const selectedPage = Number(params.get('page')) || null; + const selectedPage = params.get('page'); const raw = params.get('section'); const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const { ready, rootSet } = useReady(); @@ -89,6 +89,15 @@ export const NavPanel = () => { */ search.delete('page'); + /* + `record` is dead and rides along in every URL until something removes it. + + It belonged to the per-page record panels — Gallery, Drops, Collabs — which are unreachable now. + Nothing reads it, so it is not a bug, but every link anybody copies carries a parameter naming a + row in a section that no longer exists. + */ + search.delete('record'); + const body = ( <> @@ -144,10 +153,10 @@ export const NavPanel = () => { {pages.map((page) => { const childSearch = new URLSearchParams(params); childSearch.set('section', 'pages'); - childSearch.set('page', String(page.id)); + childSearch.set('page', pageParam(page)); childSearch.delete('media'); - const current = active === 'pages' && selectedPage === page.id; + const current = active === 'pages' && selectedPage === pageParam(page); return ( { clientRef.current = client; const [params] = useSearchParams(); - const id = Number(params.get('page')) || null; + const slug = params.get('page'); const devPort = useDevPort(); const { pages, announce } = usePages(); @@ -157,7 +157,7 @@ export const PageBuilderPanel = () => { .catch((failure: unknown) => setError(readableError(failure))); }, [pages, announce]); - const page = pages?.find((row) => row.id === id) ?? null; + const page = pageFromParam(pages, slug); /* The page's sections, which the canvas needs to name and outline what it finds in the document. diff --git a/web/usePages.ts b/web/usePages.ts index 2e4f693..4580b37 100644 --- a/web/usePages.ts +++ b/web/usePages.ts @@ -41,3 +41,22 @@ export const usePages = () => { 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; +};