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:
+40
-4
@@ -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 `/<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: [
|
||||
{
|
||||
key: 'fofos-admin',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+16
-7
@@ -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 <Navigate to={href(DEFAULT_SECTION)} replace />;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
+19
-46
@@ -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'
|
||||
|
||||
+5
-3
@@ -1,4 +1,5 @@
|
||||
import { Images } from 'lucide-react';
|
||||
import { href } from './routes';
|
||||
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
|
||||
file browser that would then have to explain where the result went.
|
||||
*/
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('section', 'galleries');
|
||||
// The section is a PATH now, so this names it rather than editing the current query. `create` and
|
||||
// `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('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.
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user