Files
proxy-manager/web/NavPanel.tsx
T
pastilhasandClaude Opus 5 417e091dfe a nav down the left, whatever it selects beside it
two fixed panels could show two of NPM's seven sections and had nowhere to put
the other five. now the left column is the section list — proxy hosts,
redirections, streams, 404 hosts, access lists, certificates, connection — and
the right renders whichever is chosen.

NPM's own vocabulary, in NPM's own order. somebody who has used it should find
the same words, and somebody who has not will end up in NPM's UI eventually
anyway; better names would just mean learning both. "404 hosts" reads oddly and
is theirs, so the hint under it says what it means.

the two panels never speak. the nav writes ?section= and the detail pane reads
it — navigation-audit's rule, and it is what makes a section linkable and
survive a reload. changing section clears ?host=, which belonged to the list
that was open.

three things folded up while doing it. the unconfigured check moved to the
detail pane and happens once, so all seven sections say "no connection yet" the
same way instead of six saying it badly. the settings gear is gone: connection
is a nav row now. and the four list-only sections are one component, differing
only in what a row says — proxy hosts and certificates stay separate because
they have real behaviour, and folding them in would be a props bag that is a
worse version of both.

the counts in the nav are one request each. affordable against a local NPM, and
they answer "is this worth opening" without a click. a count that fails is
absent rather than zero — zero is a fact about NPM, a failure is a fact about
the request, and the detail pane reports that properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:00:24 +00:00

104 lines
3.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { SECTIONS, DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
import { useConnectionChanged } from './channel';
/**
* The left column: which part of Nginx Proxy Manager 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 rather than being handed it through a channel. The
* two panels never talk to each other.
*
* ── The counts ──
*
* Each section fetches its own, which is one request per section on mount. That is affordable here — they
* are small JSON lists from a local NPM — and it buys the thing a bare nav cannot give you: whether a
* section is worth opening. "Streams 0" answers a question without a click.
*
* A count that fails is simply absent. A nav is not the place to report that NPM is unreachable; the
* detail panel says so properly, once, with the reason.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
const useSectionCounts = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [counts, setCounts] = useState<Partial<Record<SectionKey, number>>>({});
const [connectionSignal] = useConnectionChanged();
const load = useCallback(async () => {
const results = await Promise.all(
SECTIONS.filter((s) => s.path).map(async (section) => {
try {
const rows = await clientRef.current.get<unknown[]>(section.path!);
return [section.key, Array.isArray(rows) ? rows.length : undefined] as const;
} catch {
// Absent, not zero. Zero is a fact about NPM; a failure is a fact about the request.
return [section.key, undefined] as const;
}
}),
);
setCounts(Object.fromEntries(results.filter(([, n]) => n !== undefined)));
}, []);
useEffect(() => {
void load();
}, [load, connectionSignal]);
return counts;
};
export const NavPanel = () => {
const [params, setParams] = useSearchParams();
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const counts = useSectionCounts();
return (
<nav className="flex size-full flex-col overflow-auto">
{SECTIONS.map((section) => {
const Icon = section.icon;
const count = counts[section.key];
return (
<button
key={section.key}
type="button"
onClick={() =>
setParams(
(p) => {
p.set('section', section.key);
// The selected host belongs to the section that was open. Carrying it across would
// point the detail panel at a row from a different list.
p.delete('host');
return p;
},
{ replace: true },
)
}
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.label}</span>
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
</span>
<span className="block truncate text-xs text-muted-foreground">{section.hint}</span>
</span>
</button>
);
})}
</nav>
);
};