Files
proxy-manager/web/NavPanel.tsx
T
pastilhasandClaude Opus 5 219625ec82 our order, not NPM's — and the two sections this is heading towards
the order is a claim about what people come here for: proxy hosts,
certificates, streams, then the rest. this file argued the opposite yesterday
— "NPM's words in NPM's order" — and half of that was wrong. the VOCABULARY is
worth keeping, because somebody will end up in NPM's UI eventually and better
names just mean learning both. the ORDER is an accident of how NPM grew, and
reproducing it gives up the one thing a plugin is for.

and two new entries, listed and not built: Domains, and DNS Providers. they sit
below a line, with Connection, because they are OFFICER's own rather than
things NPM owns — what it knows about your domains, and the registrar keys it
holds.

shown rather than hidden on purpose. a nav that shows them says what this is
FOR, including to whoever opens it in six months wondering what was decided.
the pane says plainly that it does not exist and then what it will be — "not
built" alone reads as an error, a description alone reads as something broken,
and together they read as a plan.

the planned check runs BEFORE the connection check: "you have not connected
NPM" is not why Domains is empty, and would be a misleading thing to be told.

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

108 lines
4.1 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 { useNpmChanged } 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] = useNpmChanged();
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.planned ? 'text-muted-foreground' : ''}`}>
{section.label}
</span>
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
{/* Dimmed and labelled rather than hidden — see `sections.ts`. */}
{section.planned && <span className="text-[10px] uppercase text-muted-foreground/70">soon</span>}
</span>
<span className="block truncate text-xs text-muted-foreground">{section.hint}</span>
</span>
</button>
);
})}
</nav>
);
};