Files
fofos-admin/web/NavPanel.tsx
T
pastilhasandClaude Opus 5 8c9e4ecb85 Layout's three bands are nav rows
Top Menu, Content, Footer — the layout IS a page, and those are the only three things on it. They were
reachable by scrolling and by knowing which tab held what; as rows they take the shape the nav already
teaches with the pages under Pages, and the open one is `band` in the query string.

Fixed rather than fetched, unlike pages. A band cannot be added or removed from the admin — only the
code that draws it can — so reading them from the database would be a query that can only ever return
the same three rows in the same order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:19:53 +01:00

241 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.
const search = new URLSearchParams(params);
search.set('section', section.key);
/*
`media` is Media's own folder, and it goes when you leave.
It survived every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary URL —
a parameter naming a folder in a section that is not open, carried into every link, and shared
or bookmarked that way. It is scoped to one section, so it is that section's to keep and nobody
else's to inherit.
*/
if (section.key !== 'media') search.delete('media');
/*
`page` is Pages' own, and pressing PAGES ITSELF clears it.
Without this the section row and the row for whichever page was last open lead to the same
screen — the builder — and there is no way back to the list except the browser's back button.
Scoped like `media` above, and for the same reason: a parameter that belongs to one section
should not ride along into the others.
*/
search.delete('page');
/*
`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) => {
const childSearch = new URLSearchParams(params);
childSearch.set('section', 'layout');
childSearch.set('band', band.key);
childSearch.delete('media');
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) => {
const childSearch = new URLSearchParams(params);
childSearch.set('section', 'pages');
childSearch.set('page', pageParam(page));
childSearch.delete('media');
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>
);
};