import { Link } from 'react-router'; import { Lock } from 'lucide-react'; import { LAYOUT_BANDS, SECTIONS, sectionOpen } from './sections'; import { href, useNav } from './routes'; 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. * * `` rather than `` 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 `
`, dimmed, with no * href to cmd-click and nothing to focus. Rendering a `` 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 = () => { // 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 { section: active, item } = useNav(); 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 }); const body = ( <> {section.label} {/* 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 && ( soon )} {section.hint} {locked && } ); if (locked) { return (
{body}
); } const row = ( {body} ); /* 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 (
{row} {LAYOUT_BANDS.map((band) => { const current = active === 'layout' && item === band.key; return ( {band.label} ); })}
); } if (section.key !== 'pages' || !pages?.length) return row; return (
{row} {pages.map((page) => { const current = active === 'pages' && item === pageParam(page); return ( {page.title} {/* 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. */} {page.path} ); })}
); }; return ( ); };