diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx
index a2c8957..7031135 100644
--- a/web/DetailPanel.tsx
+++ b/web/DetailPanel.tsx
@@ -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
Loading…
;
}
+ // 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 ;
+
if (!configured || section === 'connection') return ;
switch (section) {
@@ -71,5 +77,8 @@ export const DetailPanel = () => {
return ;
case 'access-lists':
return ;
+ default:
+ // `domains` and `dns-providers` are handled above; this keeps the switch total without a cast.
+ return null;
}
};
diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx
index 4e50a10..9f01852 100644
--- a/web/NavPanel.tsx
+++ b/web/NavPanel.tsx
@@ -90,8 +90,12 @@ export const NavPanel = () => {
- {section.label}
+
+ {section.label}
+
{count !== undefined && {count}}
+ {/* Dimmed and labelled rather than hidden — see `sections.ts`. */}
+ {section.planned && soon}
{section.hint}
diff --git a/web/PlannedSection.tsx b/web/PlannedSection.tsx
new file mode 100644
index 0000000..6bcb930
--- /dev/null
+++ b/web/PlannedSection.tsx
@@ -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> = {
+ 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 (
+
+
+
{section.label} is not built yet.
+ {copy && (
+ <>
+
{copy.what}
+
{copy.why}
+ >
+ )}
+
The design is in this plugin’s README.
+
+ );
+};
diff --git a/web/sections.ts b/web/sections.ts
index 4c4f8c9..2a1c558 100644
--- a/web/sections.ts
+++ b/web/sections.ts
@@ -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,
},
];