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
|
* 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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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'));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user