From a7fc07c52ff2518ada4284dfa1077259e7361275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 04:57:29 +0100 Subject: [PATCH] sections are paths, not a query string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- manifest.ts | 44 +++++++++++++++++++-- web/DetailHeader.tsx | 9 ++--- web/DetailPanel.tsx | 23 +++++++---- web/NavPanel.tsx | 65 +++++++++---------------------- web/file-actions.ts | 8 ++-- web/routes.ts | 55 ++++++++++++++++++++++++++ web/sections/PageBuilderPanel.tsx | 5 +-- 7 files changed, 141 insertions(+), 68 deletions(-) create mode 100644 web/routes.ts diff --git a/manifest.ts b/manifest.ts index 024f292..ec38dc6 100644 --- a/manifest.ts +++ b/manifest.ts @@ -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 * 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 - * screen rather than nine routes: they are panels of one workspace, and `screens` is for a plugin - * contributing genuinely separate destinations. + * One screen at the mount prefix, labelled `label`: the sections are panels of one workspace, and + * `screens` is for a plugin contributing genuinely separate destinations with their own dock tiles. + * + * 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 = { publisher: 'officerdev', @@ -67,6 +70,39 @@ export const manifest: PluginManifest = { '@tiptap/extension-link': '^3.15.3', }, + /* + The URLs this admin has. + + ── Why they are declared rather than guessed ── + + Every plugin screen gets `/` and `//: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: [ { key: 'fofos-admin', diff --git a/web/DetailHeader.tsx b/web/DetailHeader.tsx index 19024a0..42b30ea 100644 --- a/web/DetailHeader.tsx +++ b/web/DetailHeader.tsx @@ -1,5 +1,5 @@ -import { useSearchParams } from 'react-router'; -import { DEFAULT_SECTION, isSectionKey, sectionFor } from './sections'; +import { useNav } from './routes'; +import { sectionFor } from './sections'; /** * 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. */ export const DetailHeader = () => { - const [params] = useSearchParams(); - const raw = params.get('section'); - const section = sectionFor(isSectionKey(raw) ? raw : DEFAULT_SECTION); + const { section: key } = useNav(); + const section = sectionFor(key); const Icon = section.icon; return ( diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 4e34fc4..749ddc8 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -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 { 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 { MediaPanel } from './MediaPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; @@ -98,20 +100,27 @@ const BarDeviceToggle = () => { 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 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. + 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 selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; + const { section: selected, item: selectedPage, atIndex } = useNav(); 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 ; + if (loading) { return
Loading…
; } diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 746f8da..de291b4 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -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 = ( <> @@ -118,7 +91,17 @@ export const NavPanel = () => { const row = ( @@ -153,17 +136,12 @@ export const NavPanel = () => {
{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 ( {
{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 ( { + 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}`; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 6c7fef6..5b4965c 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router'; +import { useNav } from '../routes'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; @@ -57,8 +57,7 @@ export const PageBuilderPanel = () => { const clientRef = useRef(client); clientRef.current = client; - const [params] = useSearchParams(); - const slug = params.get('page'); + const { item: slug } = useNav(); const devPort = useDevPort(); const { pages, announce } = usePages();