sections are paths, not a query string

The admin declares its routes in the manifest, so /fofos-admin/layout/header and
/fofos-admin/pages/home are real URLs. One reader takes the two segments after
the mount; links carry no query string at all, so nothing leaks between
sections.

What stays in the query is how you are looking — device, tab, view — rather than
at what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 04:57:29 +01:00
co-authored by Claude Opus 5
parent 4d7162363b
commit a7fc07c52f
7 changed files with 141 additions and 68 deletions
+19 -46
View File
@@ -1,6 +1,7 @@
import { Link, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { Lock } from 'lucide-react';
import { DEFAULT_SECTION, LAYOUT_BANDS, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections';
import { LAYOUT_BANDS, SECTIONS, sectionOpen } from './sections';
import { href, useNav } from './routes';
import { useReady } from './useReady';
import { pageParam, usePages } from './usePages';
@@ -49,13 +50,10 @@ const rowClass = (active: boolean) =>
}`;
export const NavPanel = () => {
const [params] = useSearchParams();
// 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 = params.get('page');
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { section: active, item } = useNav();
const { ready, rootSet } = useReady();
const renderRow = (section: (typeof SECTIONS)[number]) => {
@@ -64,31 +62,6 @@ export const NavPanel = () => {
// connection, Administration wants nothing because it is the way out of both.
const locked = !sectionOpen(section, { ready, rootSet });
// Everything else in the URL is preserved. This is one screen of a workspace, and the global
// query string carries state that belongs to the shell rather than to this panel.
/*
A FRESH query string, carrying only the section.
It used to copy the current one and delete the parameters known to be scoped — `media`, then
`page`, and `band` would have been the third. That is a list of exceptions that has to grow every
time a section gains a parameter, and the failure is silent: pressing Layout while a band was open
gave `?section=layout&band=footer`, so the row for the whole section led to one part of it.
Starting empty inverts the default. A parameter survives a navigation only if the link SAYS so,
which is one decision at each link rather than a list of everything that must not leak.
*/
const search = new URLSearchParams();
search.set('section', section.key);
/*
`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 = (
<>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
@@ -118,7 +91,17 @@ export const NavPanel = () => {
const row = (
<Link
key={section.key}
to={{ search: `?${search.toString()}` }}
/*
A PATH, and no query string at all.
It used to build a fresh `URLSearchParams` per link, because everything about where you were
lived in the query and a copied one leaked — pressing Layout while a band was open gave
`?section=layout&band=footer`, so the row for a whole section led to one part of it.
A path cannot leak: the URL says where you are, and `href` drops the query because what is
left in it — how you are looking, not at what — belongs to the screen you are leaving.
*/
to={href(section.key)}
replace
className={rowClass(active === section.key)}
>
@@ -153,17 +136,12 @@ export const NavPanel = () => {
<div key={section.key} className="flex flex-col">
{row}
{LAYOUT_BANDS.map((band) => {
// The section and this band, and nothing else — same rule as the section rows above.
const childSearch = new URLSearchParams();
childSearch.set('section', 'layout');
childSearch.set('band', band.key);
const current = active === 'layout' && params.get('band') === band.key;
const current = active === 'layout' && item === band.key;
return (
<Link
key={band.key}
to={{ search: `?${childSearch.toString()}` }}
to={href('layout', band.key)}
replace
className={`flex items-baseline gap-2 border-b border-border py-1.5 pr-3 pl-9 text-left transition-colors ${
current ? 'bg-muted' : 'hover:bg-muted/50'
@@ -183,17 +161,12 @@ export const NavPanel = () => {
<div key={section.key} className="flex flex-col">
{row}
{pages.map((page) => {
// The section and this page, and nothing else.
const childSearch = new URLSearchParams();
childSearch.set('section', 'pages');
childSearch.set('page', pageParam(page));
const current = active === 'pages' && selectedPage === pageParam(page);
const current = active === 'pages' && item === pageParam(page);
return (
<Link
key={page.id}
to={{ search: `?${childSearch.toString()}` }}
to={href('pages', pageParam(page))}
replace
className={`flex items-baseline gap-2 border-b border-border py-1.5 pr-3 pl-9 text-left transition-colors ${
current ? 'bg-muted' : 'hover:bg-muted/50'