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. Pressing a section carried whatever the last one had left behind. Every link built a copy of the current query string and deleted 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 it fails silently when somebody forgets: the link still works, it just goes somewhere more specific than it says. Starting from an empty query string 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. So a section row is `?section=x`, and a sub-item is that plus its own `band` or `page`. Nothing else rides along, including the device and the open tab — both are restored to their defaults by the panel that owns them, which is where a default belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import { Link, useSearchParams } from 'react-router';
|
|
import { Lock } from 'lucide-react';
|
|
import { DEFAULT_SECTION, LAYOUT_BANDS, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections';
|
|
import { useReady } from './useReady';
|
|
import { pageParam, usePages } from './usePages';
|
|
|
|
/**
|
|
* The left column: which part of the fofos site the detail panel is showing.
|
|
*
|
|
* ── Selection is in the URL ──
|
|
*
|
|
* `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a
|
|
* reload, can be linked, and the detail panel reads it directly. The two panels are siblings in a
|
|
* workspace and never speak to each other.
|
|
*
|
|
* ── Real links, not buttons ──
|
|
*
|
|
* `AGENTS.md`: "Rows and nav items are real links." `plugins/proxy-manager/web/NavPanel.tsx` uses
|
|
* buttons calling `setParams`, which keeps the URL correct but gives up anchor semantics — no
|
|
* cmd-click into a new tab, no middle-click, not link-focusable. That is the older pattern and the
|
|
* file that documents it says not to copy it.
|
|
*
|
|
* `<Link>` rather than `<NavLink>` because active state here is a QUERY parameter and NavLink's
|
|
* `isActive` compares paths — every section would light up at once. So the active class is computed
|
|
* from the same value the detail panel reads, which is the one place it can come from.
|
|
*
|
|
* ── Locked until `useReady` ──
|
|
*
|
|
* Until the plugin is ready, the site's sections are not links at all — a `<div>`, dimmed, with no
|
|
* href to cmd-click and nothing to focus. Rendering a `<Link>` and swallowing the click would leave a
|
|
* row that looks navigable, offers a URL on hover and opens in a new tab perfectly happily.
|
|
*
|
|
* The nav only declines to OFFER the door. `DetailPanel` holds the lock, because a typed URL never
|
|
* comes through here.
|
|
*/
|
|
|
|
/**
|
|
* The nav has a FOOT.
|
|
*
|
|
* Everything above it is a part of the client's site, in sitemap order. Below the divider is this
|
|
* plugin's own machinery, which is not a page and does not belong in a list of them — the same split
|
|
* the plugins nav makes with Update-all, and the reason it sits at the bottom of the panel rather than
|
|
* at the bottom of the list. `mt-auto` pins it there whether there are ten sections or two.
|
|
*/
|
|
|
|
const rowClass = (active: boolean) =>
|
|
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
|
|
active ? 'bg-muted' : 'hover:bg-muted/50'
|
|
}`;
|
|
|
|
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 { ready, rootSet } = useReady();
|
|
|
|
const renderRow = (section: (typeof SECTIONS)[number]) => {
|
|
const Icon = section.icon;
|
|
// Each section asks its own question now — Media wants a root location, the site's pages want a
|
|
// 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" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-baseline gap-2">
|
|
<span className={`truncate text-sm ${section.built ? '' : 'text-muted-foreground'}`}>{section.label}</span>
|
|
{/* Dimmed and labelled rather than hidden. A nav that shows only what is finished gives
|
|
no sense of what the thing is for — see sections.ts. */}
|
|
{!section.built && (
|
|
<span className="text-[10px] tracking-wide text-muted-foreground/70 uppercase">soon</span>
|
|
)}
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">{section.hint}</span>
|
|
</span>
|
|
{locked && <Lock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/60" />}
|
|
</>
|
|
);
|
|
|
|
if (locked) {
|
|
return (
|
|
<div key={section.key} aria-disabled className={`${rowClass(false)} cursor-not-allowed opacity-45`}>
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const row = (
|
|
<Link
|
|
key={section.key}
|
|
to={{ search: `?${search.toString()}` }}
|
|
replace
|
|
className={rowClass(active === section.key)}
|
|
>
|
|
{body}
|
|
</Link>
|
|
);
|
|
|
|
/*
|
|
The site's pages, as rows under Pages.
|
|
|
|
Indented and quieter than a section, because they are not sections — they are the contents of
|
|
one. The indent is a left border rather than padding alone, so the relationship survives a row
|
|
wrapping onto two lines.
|
|
|
|
Drawn whether or not Pages is the open section. The list of pages IS the site, and hiding it
|
|
until you click makes the nav a menu of menus.
|
|
*/
|
|
/*
|
|
Layout's three bands, as rows under Layout.
|
|
|
|
The layout IS a page — a header, the slot a page goes in, and a footer — and those three are the
|
|
only things on this screen. They were reachable by scrolling and by knowing which tab held what;
|
|
as rows they are the same shape as the pages under Pages, which is the shape the nav already
|
|
teaches.
|
|
|
|
Fixed rather than fetched: unlike pages, these three are structural. A band cannot be added or
|
|
removed from the admin, only the code that draws it can, so a list read from the database would
|
|
be a query that can only ever return the same three rows.
|
|
*/
|
|
if (section.key === 'layout') {
|
|
return (
|
|
<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;
|
|
|
|
return (
|
|
<Link
|
|
key={band.key}
|
|
to={{ search: `?${childSearch.toString()}` }}
|
|
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'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{band.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (section.key !== 'pages' || !pages?.length) return row;
|
|
|
|
return (
|
|
<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);
|
|
|
|
return (
|
|
<Link
|
|
key={page.id}
|
|
to={{ search: `?${childSearch.toString()}` }}
|
|
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'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{page.title}</span>
|
|
{/* The address, because two pages can be called nearly the same thing and this is what
|
|
tells them apart. Home's is `/`, which is short enough not to need explaining. */}
|
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<nav className="flex size-full flex-col overflow-auto">
|
|
<div className="flex flex-col">{SECTIONS.filter((section) => !section.pinned).map(renderRow)}</div>
|
|
|
|
{/* Separated by the same border the rows use, so it reads as the end of the panel rather than as
|
|
one more page of the site. `border-t` and not `border-t-2`: it is a divider, not a heading. */}
|
|
<div className="mt-auto flex flex-col border-t border-border">
|
|
{/* Said once, here, rather than on nine dimmed rows. Without it a locked nav reads as broken
|
|
rather than as one step not done — the rows go grey and nothing says why or what to do. */}
|
|
{!ready && (
|
|
<p className="px-3 pt-2.5 text-xs text-muted-foreground">
|
|
{rootSet ? 'Connect fofos below to open the rest.' : 'Set a root location below to begin.'}
|
|
</p>
|
|
)}
|
|
{SECTIONS.filter((section) => section.pinned).map(renderRow)}
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|