every section was clickable and every one of them showed the same connection form, because the panel forced it and the nav did not know. nine doors onto one message taught nobody anything and made a setup step look like nine faults. so there is now a gate, `useReady`, and both panels read it. that is the point of putting it in a hook rather than in either panel: a nav that offers a row the detail panel then refuses is worse than one that does not offer it. the nav DECLINES TO OFFER — a locked section is a <div>, not a <Link>, so there is no href to hover, no cmd-click into a new tab and nothing to focus. rendering a link and swallowing the click leaves a row that looks navigable and behaves like one everywhere except the click. the detail panel HOLDS THE LOCK, and it has to, because `?section=gallery` typed into the address bar or arriving from an old bookmark never passes through the nav. one line of copy in the foot rather than a badge on nine dimmed rows. without it a locked nav reads as broken instead of as one step not done: the rows go grey and nothing says why or what to do about it. THE CONDITION IS HARDCODED FALSE, as asked. the plumbing around it is real — it asks /status, re-asks when the connection changes, and reports while it is still asking — so switching the gate on is one line. it returns a single boolean and not its parts deliberately: "all the info needed in Administration" will grow past `configured && lastProbeOk`, and callers must not start assembling their own version of it out of the pieces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
5.1 KiB
TypeScript
119 lines
5.1 KiB
TypeScript
import { Link, useSearchParams } from 'react-router';
|
|
import { Lock } from 'lucide-react';
|
|
import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections';
|
|
import { useReady } from './useReady';
|
|
|
|
/**
|
|
* 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();
|
|
const raw = params.get('section');
|
|
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
|
|
const { ready } = useReady();
|
|
|
|
const renderRow = (section: (typeof SECTIONS)[number]) => {
|
|
const Icon = section.icon;
|
|
// Administration is never locked — it is the way OUT of being locked.
|
|
const locked = !ready && !section.pinned;
|
|
|
|
// 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);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={section.key}
|
|
to={{ search: `?${search.toString()}` }}
|
|
replace
|
|
className={rowClass(active === section.key)}
|
|
>
|
|
{body}
|
|
</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">
|
|
{/* 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">Connect fofos below to open the rest.</p>}
|
|
{SECTIONS.filter((section) => section.pinned).map(renderRow)}
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|