Files
fofos-admin/web/NavPanel.tsx
T
pastilhasandClaude Opus 5 5e96274ebd Administration sits at the foot of the panel, not the end of the list
it was last in SECTIONS, so it rendered as the tenth row and then stopped —
directly under Messages, with empty panel beneath it. that is the bottom of the
LIST. what it wanted is the bottom of the PANEL, the way Update-all sits in the
plugins nav.

`mt-auto` on a second group, divided by the same border the rows already use. it
holds whether there are ten sections or two, and it does not need a spacer element
to push it down.

the split is a `pinned` flag on the section rather than the nav testing for the
key. which sections are the client's site and which are this plugin's own
machinery is a fact about the section, and the nav should be reading it, not
knowing it. one array still — `sectionFor` and `isSectionKey` have to resolve
every key, and a second exported list is a second place to forget one.

the row body came out into `renderRow` so both groups draw the same row. no
change to what a row looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:20:39 +01:00

87 lines
3.8 KiB
TypeScript

import { Link, useSearchParams } from 'react-router';
import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections';
/**
* 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.
*/
/**
* 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();
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const renderRow = (section: (typeof SECTIONS)[number]) => {
const Icon = section.icon;
// 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);
return (
<Link
key={section.key}
to={{ search: `?${search.toString()}` }}
replace
className={rowClass(active === section.key)}
>
<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>
</Link>
);
};
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">
{SECTIONS.filter((section) => section.pinned).map(renderRow)}
</div>
</nav>
);
};