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
+40 -4
View File
@@ -27,11 +27,14 @@ import type { PluginManifest } from '@@/plugins/manifest';
* is `ownerOnly` — the split that field exists for, and the same shape proxy-manager and Jellyfin use * is `ownerOnly` — the split that field exists for, and the same shape proxy-manager and Jellyfin use
* for the same reason. * for the same reason.
* *
* ── No `screens` ── * ── No `screens`, but a route table ──
* *
* One screen at the mount prefix, labelled `label`. The nine sections are `?section=` on that one * One screen at the mount prefix, labelled `label`: the sections are panels of one workspace, and
* screen rather than nine routes: they are panels of one workspace, and `screens` is for a plugin * `screens` is for a plugin contributing genuinely separate destinations with their own dock tiles.
* contributing genuinely separate destinations. *
* They ARE routes, though. `routes` below names each one, which is what moved the sections out of the
* query string and into the path — and what lets a route arrange the screen's panels its own way when
* one of them needs to.
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
@@ -67,6 +70,39 @@ export const manifest: PluginManifest = {
'@tiptap/extension-link': '^3.15.3', '@tiptap/extension-link': '^3.15.3',
}, },
/*
The URLs this admin has.
── Why they are declared rather than guessed ──
Every plugin screen gets `/<route>` and `/<route>/:section` for free, which is enough for one
segment. These need two — `/fofos-admin/layout/header`, `/fofos-admin/pages/home` — and naming them
is what makes each one a place the platform knows about rather than a shape it inferred.
── They all share one layout, for now ──
No `layout` on any entry, so each renders `web/layout.ts`. That is the point of declaring them
anyway: the day the page builder wants a third column and Layout does not, it is one field here
rather than a new mechanism.
── The pairs ──
`layout` and `layout/:band` are two entries rather than `layout/:band?`, so either can take its own
arrangement later. React-router ranks a static segment above a dynamic one, so `/pages/home` cannot
be swallowed by something broader.
*/
routes: [
// The bare screen. Redirected to Layout by the detail panel — see DEFAULT_SECTION.
{ path: '' },
{ path: 'layout' },
{ path: 'layout/:band' },
{ path: 'pages' },
{ path: 'pages/:slug' },
{ path: 'galleries' },
{ path: 'media' },
{ path: 'administration' },
],
permissions: [ permissions: [
{ {
key: 'fofos-admin', key: 'fofos-admin',
+4 -5
View File
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router'; import { useNav } from './routes';
import { DEFAULT_SECTION, isSectionKey, sectionFor } from './sections'; import { sectionFor } from './sections';
/** /**
* The detail panel's chrome header — the section's own name, not the word "Detail". * The detail panel's chrome header — the section's own name, not the word "Detail".
@@ -26,9 +26,8 @@ import { DEFAULT_SECTION, isSectionKey, sectionFor } from './sections';
* area and always reachable. * area and always reachable.
*/ */
export const DetailHeader = () => { export const DetailHeader = () => {
const [params] = useSearchParams(); const { section: key } = useNav();
const raw = params.get('section'); const section = sectionFor(key);
const section = sectionFor(isSectionKey(raw) ? raw : DEFAULT_SECTION);
const Icon = section.icon; const Icon = section.icon;
return ( return (
+16 -7
View File
@@ -1,7 +1,9 @@
import { useSearchParams } from 'react-router'; import { Navigate } from 'react-router';
import { href, useNav } from './routes';
import { DEFAULT_SECTION } from './sections';
import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react'; import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { sectionFor, sectionOpen, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel'; import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel'; import { MediaPanel } from './MediaPanel';
import { GalleriesPanel } from './sections/GalleriesPanel'; import { GalleriesPanel } from './sections/GalleriesPanel';
@@ -98,20 +100,27 @@ const BarDeviceToggle = () => {
export const DetailPanel = () => { 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. By SLUG, so the URL 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. 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. The second path segment is what distinguishes the two, which keeps both
addressable: `/fofos-admin/pages` is the list, `/fofos-admin/pages/home` is that page.
*/ */
const selectedPage = params.get('page'); const { section: selected, item: selectedPage, atIndex } = useNav();
const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { ready, rootSet, loading } = useReady(); const { ready, rootSet, loading } = useReady();
/*
The bare `/fofos-admin` redirected to its first section, once, replacing.
The screen has to render SOMETHING there, and rendering Layout while the address bar says nothing
means the URL stops describing what is on screen — which is the whole reason the sections moved out
of the query string. `replace`, so Back leaves the admin rather than bouncing off the redirect.
*/
if (atIndex) return <Navigate to={href(DEFAULT_SECTION)} replace />;
if (loading) { if (loading) {
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>; return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
} }
+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 { 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 { useReady } from './useReady';
import { pageParam, usePages } from './usePages'; import { pageParam, usePages } from './usePages';
@@ -49,13 +50,10 @@ const rowClass = (active: boolean) =>
}`; }`;
export const NavPanel = () => { export const NavPanel = () => {
const [params] = useSearchParams();
// 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 = params.get('page'); const { section: active, item } = useNav();
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { ready, rootSet } = useReady(); const { ready, rootSet } = useReady();
const renderRow = (section: (typeof SECTIONS)[number]) => { 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. // connection, Administration wants nothing because it is the way out of both.
const locked = !sectionOpen(section, { ready, rootSet }); 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 = ( 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" />
@@ -118,7 +91,17 @@ export const NavPanel = () => {
const row = ( const row = (
<Link <Link
key={section.key} 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 replace
className={rowClass(active === section.key)} className={rowClass(active === section.key)}
> >
@@ -153,17 +136,12 @@ export const NavPanel = () => {
<div key={section.key} className="flex flex-col"> <div key={section.key} className="flex flex-col">
{row} {row}
{LAYOUT_BANDS.map((band) => { {LAYOUT_BANDS.map((band) => {
// The section and this band, and nothing else — same rule as the section rows above. const current = active === 'layout' && item === band.key;
const childSearch = new URLSearchParams();
childSearch.set('section', 'layout');
childSearch.set('band', band.key);
const current = active === 'layout' && params.get('band') === band.key;
return ( return (
<Link <Link
key={band.key} key={band.key}
to={{ search: `?${childSearch.toString()}` }} to={href('layout', band.key)}
replace replace
className={`flex items-baseline gap-2 border-b border-border py-1.5 pr-3 pl-9 text-left transition-colors ${ 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' current ? 'bg-muted' : 'hover:bg-muted/50'
@@ -183,17 +161,12 @@ export const NavPanel = () => {
<div key={section.key} className="flex flex-col"> <div key={section.key} className="flex flex-col">
{row} {row}
{pages.map((page) => { {pages.map((page) => {
// The section and this page, and nothing else. const current = active === 'pages' && item === pageParam(page);
const childSearch = new URLSearchParams();
childSearch.set('section', 'pages');
childSearch.set('page', pageParam(page));
const current = active === 'pages' && selectedPage === pageParam(page);
return ( return (
<Link <Link
key={page.id} key={page.id}
to={{ search: `?${childSearch.toString()}` }} to={href('pages', pageParam(page))}
replace replace
className={`flex items-baseline gap-2 border-b border-border py-1.5 pr-3 pl-9 text-left transition-colors ${ 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' current ? 'bg-muted' : 'hover:bg-muted/50'
+5 -3
View File
@@ -1,4 +1,5 @@
import { Images } from 'lucide-react'; import { Images } from 'lucide-react';
import { href } from './routes';
import type { FileAction } from 'officerdev'; import type { FileAction } from 'officerdev';
/** /**
@@ -81,11 +82,12 @@ export const fileActions: FileAction[] = [
and watching the items land belongs on the screen that owns galleries, not in a modal over a and watching the items land belongs on the screen that owns galleries, not in a modal over a
file browser that would then have to explain where the result went. file browser that would then have to explain where the result went.
*/ */
const params = new URLSearchParams(window.location.search); // The section is a PATH now, so this names it rather than editing the current query. `create` and
params.set('section', 'galleries'); // `from` stay in the query: they are a one-shot handover, not where you are.
const params = new URLSearchParams();
params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : ''); params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : '');
params.set('from', paths.join('\n')); params.set('from', paths.join('\n'));
window.history.pushState({}, '', `${window.location.pathname}?${params.toString()}`); window.history.pushState({}, '', `${href('galleries')}?${params.toString()}`);
// The router listens for this; pushState alone does not notify it. // The router listens for this; pushState alone does not notify it.
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
}, },
+55
View File
@@ -0,0 +1,55 @@
import { useLocation } from 'react-router';
import { DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
/**
* Where this admin is in the URL, read from the PATH.
*
* ── The path, not the query string ──
*
* It was `?section=layout&band=header`. Everything about where you were lived in the query, which made
* every link a fresh `URLSearchParams` and every navigation a decision about what to carry — see the
* note NavPanel used to hold about parameters leaking between sections. A path has no such question:
* `/fofos-admin/layout/header` says where you are and nothing else, and what remains in the query is
* genuinely about how you are looking rather than at what.
*
* The routes themselves are declared in the manifest, so the platform knows they exist — which is what
* lets a route carry its own arrangement of panels rather than sharing one with the whole screen.
*
* ── Why the pathname and not `useParams` ──
*
* Which parameters exist depends on which route matched: `/fofos-admin/layout/header` has a `band` and
* `/fofos-admin/galleries` has nothing. A panel would then have to know its own route to read its own
* position, and every panel is rendered on all of them.
*
* Two segments after the mount, always in the same places. One reader, every route.
*/
export const MOUNT = '/fofos-admin';
/**
* The section and its sub-item, from the two segments after the mount.
*
* `atIndex` is the bare `/fofos-admin`, which is a real state and not the same as Layout: the panel
* redirects, so the address bar ends up saying where you are. `section` is still defaulted, so
* everything that only wants to draw a highlight has one answer rather than a branch.
*/
export const useNav = (): { section: SectionKey; item: string | null; atIndex: boolean } => {
const { pathname } = useLocation();
const rest = pathname.startsWith(MOUNT) ? pathname.slice(MOUNT.length) : '';
const [first = '', second = ''] = rest.replace(/^\//, '').split('/');
return {
// An unknown segment falls back rather than 404s: the URL is a thing people type and edit.
section: isSectionKey(first) ? first : DEFAULT_SECTION,
item: second ? decodeURIComponent(second) : null,
atIndex: first === '',
};
};
/**
* A link to a section, or to something inside one.
*
* The query string is dropped, and that is the point rather than an omission. `?device=tablet` is how
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
*/
export const href = (section: SectionKey, item?: string | null): string =>
item ? `${MOUNT}/${section}/${encodeURIComponent(item)}` : `${MOUNT}/${section}`;
+2 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useNav } from '../routes';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { readableError } from '../errors'; import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview'; import { SitePreview, useDevPort } from '../SitePreview';
@@ -57,8 +57,7 @@ export const PageBuilderPanel = () => {
const clientRef = useRef(client); const clientRef = useRef(client);
clientRef.current = client; clientRef.current = client;
const [params] = useSearchParams(); const { item: slug } = useNav();
const slug = params.get('page');
const devPort = useDevPort(); const devPort = useDevPort();
const { pages, announce } = usePages(); const { pages, announce } = usePages();