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>
This commit is contained in:
2026-08-31 04:42:16 +01:00
co-authored by Claude Opus 5
parent b7c887a9e8
commit 5b1c5a2590
4 changed files with 38 additions and 9 deletions
+3 -2
View File
@@ -153,12 +153,13 @@ export const DetailPanel = () => {
const [params] = useSearchParams(); const [params] = useSearchParams();
const raw = params.get('section'); 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 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. 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 selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { ready, rootSet, loading } = useReady(); const { ready, rootSet, loading } = useReady();
+13 -4
View File
@@ -2,7 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections'; import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections';
import { useReady } from './useReady'; 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. * 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 // 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. // is why nothing is drawn instead of an empty list.
const { pages } = usePages(); const { pages } = usePages();
const selectedPage = Number(params.get('page')) || null; const selectedPage = params.get('page');
const raw = params.get('section'); const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { ready, rootSet } = useReady(); const { ready, rootSet } = useReady();
@@ -89,6 +89,15 @@ export const NavPanel = () => {
*/ */
search.delete('page'); 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 = ( const body = (
<> <>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> <Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
@@ -144,10 +153,10 @@ export const NavPanel = () => {
{pages.map((page) => { {pages.map((page) => {
const childSearch = new URLSearchParams(params); const childSearch = new URLSearchParams(params);
childSearch.set('section', 'pages'); childSearch.set('section', 'pages');
childSearch.set('page', String(page.id)); childSearch.set('page', pageParam(page));
childSearch.delete('media'); childSearch.delete('media');
const current = active === 'pages' && selectedPage === page.id; const current = active === 'pages' && selectedPage === pageParam(page);
return ( return (
<Link <Link
+3 -3
View File
@@ -7,7 +7,7 @@ import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas'; import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextDialog } from './FreeTextDialog'; import { FreeTextDialog } from './FreeTextDialog';
import { usePages, type Page } from '../usePages'; import { pageFromParam, usePages, type Page } from '../usePages';
/** /**
* One page, being built. * One page, being built.
@@ -30,7 +30,7 @@ export const PageBuilderPanel = () => {
clientRef.current = client; clientRef.current = client;
const [params] = useSearchParams(); const [params] = useSearchParams();
const id = Number(params.get('page')) || null; const slug = params.get('page');
const devPort = useDevPort(); const devPort = useDevPort();
const { pages, announce } = usePages(); const { pages, announce } = usePages();
@@ -157,7 +157,7 @@ export const PageBuilderPanel = () => {
.catch((failure: unknown) => setError(readableError(failure))); .catch((failure: unknown) => setError(readableError(failure)));
}, [pages, announce]); }, [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. The page's sections, which the canvas needs to name and outline what it finds in the document.
+19
View File
@@ -41,3 +41,22 @@ export const usePages = () => {
return { pages, announce }; 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;
};