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
This commit is contained in:
2026-08-25 19:11:47 +00:00
co-authored by Claude Opus 5
parent 66383aa9a4
commit 219625ec82
4 changed files with 120 additions and 23 deletions
+10 -1
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections';
import { HostsPanel } from './HostsPanel';
import { CertificatesPanel } from './CertificatesPanel';
import { ConnectionPanel } from './ConnectionPanel';
import { ListSection, REDIRECTIONS, STREAMS, DEAD_HOSTS, ACCESS_LISTS } from './ListSection';
import { PlannedSection } from './PlannedSection';
import { useNpmChanged } from './channel';
/**
@@ -56,6 +57,11 @@ export const DetailPanel = () => {
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
}
// A planned section says so before anything else — including before the connection check, because "you
// have not connected NPM" is not why Domains is empty and would be a misleading thing to be told.
const planned = SECTIONS.find((s) => s.key === section && s.planned);
if (planned) return <PlannedSection section={planned} />;
if (!configured || section === 'connection') return <ConnectionPanel />;
switch (section) {
@@ -71,5 +77,8 @@ export const DetailPanel = () => {
return <ListSection {...DEAD_HOSTS} />;
case 'access-lists':
return <ListSection {...ACCESS_LISTS} />;
default:
// `domains` and `dns-providers` are handled above; this keeps the switch total without a cast.
return null;
}
};
+5 -1
View File
@@ -90,8 +90,12 @@ export const NavPanel = () => {
<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>
<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>
+43
View File
@@ -0,0 +1,43 @@
import { Hammer } from 'lucide-react';
import type { Section } from './sections';
/**
* A section that is agreed and not built.
*
* It says so plainly, and then says what it will be. Both halves matter: "not built" alone reads as an
* error, and a description alone reads as a feature that is broken. Together they read as a plan, which is
* what they are.
*
* The alternative was to hide these until they exist, and that loses the thing the nav is good at — a
* person opening this plugin can see what it is FOR, not only what it currently does. `README.md` carries
* the full design; this is the two sentences that fit on a screen.
*/
const COPY: Partial<Record<Section['key'], { what: string; why: string }>> = {
domains: {
what: 'The domains you manage on this machine, and who each one is registered with.',
why: 'Right now that list lives in a document somebody edits by hand, which is right on the day it is written. Officer knowing it for real is what lets a certificate be issued without being told the same facts again.',
},
'dns-providers': {
what: 'An API key per registrar — Cloudflare, GoDaddy, whoever — kept in the secret store and never handed back out.',
why: 'This is what makes HTTPS possible on a hostname that only exists on your private network. Let’s Encrypt cannot visit a private address, so the only proof it accepts is a DNS record, and placing one needs the registrar’s API.',
},
};
export const PlannedSection = ({ section }: { section: Section }) => {
const copy = COPY[section.key];
return (
<div className="flex size-full flex-col items-center justify-center gap-3 p-8 text-center">
<Hammer className="size-8 text-muted-foreground" />
<p className="text-sm font-medium">{section.label} is not built yet.</p>
{copy && (
<>
<p className="max-w-md text-sm text-muted-foreground">{copy.what}</p>
<p className="max-w-md text-[11px] leading-relaxed text-muted-foreground">{copy.why}</p>
</>
)}
<p className="text-[11px] text-muted-foreground">The design is in this plugin’s README.</p>
</div>
);
};
+62 -21
View File
@@ -1,24 +1,38 @@
import type { LucideIcon } from 'lucide-react';
import { Globe, Cable, Radio, FileX2, Users, ShieldCheck, Plug } from 'lucide-react';
import { Globe, Cable, Radio, FileX2, Users, ShieldCheck, Plug, Network, KeyRound } from 'lucide-react';
/**
* The sections, mirroring Nginx Proxy Manager's own tabs.
* The sections.
*
* Deliberately NPM's vocabulary rather than ours. Somebody who has used NPM should find the same words in
* the same order, and somebody who has not is going to end up in NPM's UI eventually anyway — inventing
* better names would only mean they have to learn both.
* ── NPM's words, not NPM's order ──
*
* `404 Hosts` is the one that reads oddly, and it is NPM's own name: a domain served deliberately as
* nothing. The description says so, because the label cannot.
* The VOCABULARY is deliberately NPM's. Somebody who has used it should find the same words, and somebody
* who has not will end up in NPM's UI eventually anyway — inventing better names only means learning both.
* `404 Hosts` reads oddly and is theirs; the hint says what it means, because the label cannot.
*
* The ORDER is ours, and this file said the opposite until 2026-08-25. NPM's nav order is an accident of
* how NPM grew; ours is a claim about what people come here for. Proxy hosts, then certificates, then
* streams — that is most of every visit. The rest are real and rarely the reason you opened this.
*
* **Being able to make that claim is most of why this is a plugin** rather than a link to NPM. A wrapper
* that reproduces the original's ordering has given up the one thing it was for.
*
* ── Two groups ──
*
* Everything above `domains` is a thing NPM owns and this reads. From `domains` down is OFFICER's own —
* what it knows about your domains, the DNS credentials it holds, and where NPM is. Those are not NPM
* concepts and should not pretend to be.
*/
export type SectionKey =
| 'proxy-hosts'
| 'redirections'
| 'certificates'
| 'streams'
| 'redirections'
| 'dead-hosts'
| 'access-lists'
| 'certificates'
| 'domains'
| 'dns-providers'
| 'connection';
export type Section = {
@@ -27,8 +41,17 @@ export type Section = {
icon: LucideIcon;
/** One line under the label in the nav. What this is, for somebody who does not already know. */
hint: string;
/** The plugin route its count comes from, or null for the settings pane. */
/** The plugin route its count comes from, or null when there is nothing to count. */
path: string | null;
/**
* Agreed and not built.
*
* Listed rather than hidden on purpose: these two are the shape the plugin is going towards, and a nav
* that shows them says what this is FOR to somebody who opens it — including the owner, six months from
* now, wondering what was decided. The pane says plainly that it does not exist, which is better than a
* feature that looks half-present.
*/
planned?: boolean;
};
export const SECTIONS: Section[] = [
@@ -40,11 +63,11 @@ export const SECTIONS: Section[] = [
path: '/proxy-manager/hosts',
},
{
key: 'redirections',
label: 'Redirection Hosts',
icon: Cable,
hint: 'A domain that sends visitors somewhere else',
path: '/proxy-manager/redirections',
key: 'certificates',
label: 'Certificates',
icon: ShieldCheck,
hint: 'TLS, and how long each one has left',
path: '/proxy-manager/certificates',
},
{
key: 'streams',
@@ -53,6 +76,13 @@ export const SECTIONS: Section[] = [
hint: 'A raw TCP or UDP port, forwarded',
path: '/proxy-manager/streams',
},
{
key: 'redirections',
label: 'Redirection Hosts',
icon: Cable,
hint: 'A domain that sends visitors somewhere else',
path: '/proxy-manager/redirections',
},
{
key: 'dead-hosts',
label: '404 Hosts',
@@ -67,18 +97,29 @@ export const SECTIONS: Section[] = [
hint: 'Who is allowed to reach a host',
path: '/proxy-manager/access-lists',
},
// ── Officer's own, below here ──
{
key: 'certificates',
label: 'Certificates',
icon: ShieldCheck,
hint: 'TLS, and how long each one has left',
path: '/proxy-manager/certificates',
key: 'domains',
label: 'Domains',
icon: Network,
hint: 'The domains you manage, and who registers each',
path: null,
planned: true,
},
{
key: 'dns-providers',
label: 'DNS Providers',
icon: KeyRound,
hint: 'API keys, so a private hostname can still get TLS',
path: null,
planned: true,
},
{
key: 'connection',
label: 'Connection',
icon: Plug,
hint: "Where NPM is, and the credential — owner only",
hint: 'Where NPM is, and the credential — owner only',
path: null,
},
];