diff --git a/manifest.ts b/manifest.ts index bb5cf8b..19020c7 100644 --- a/manifest.ts +++ b/manifest.ts @@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.29.03', + version: '2026.08.29.04', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 23f4ac1..8d29f4f 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; -import { useClient } from 'hooks/useClient'; import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections'; import { ConnectionPanel } from './ConnectionPanel'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; -import { useFofosChanged } from './channel'; +import { useReady } from './useReady'; /** * The right column: whichever section the nav has selected. @@ -20,16 +18,14 @@ import { useFofosChanged } from './channel'; * to the same screen is a second thing to keep correct, and the two disagreed the moment a section was * selected underneath a form that covered it. * - * ── The unconfigured check lives HERE, once ── + * ── Not ready means Administration, whatever the URL says ── * - * Every OTHER section needs a connection, so each would otherwise have to handle its absence and each - * would word it slightly differently. Asking `/status` once and sending an unconfigured plugin to - * Administration is one answer for all of them, and it makes "not connected yet" read as a setup step - * rather than as nine broken lists. + * The gate is `useReady`, shared with the nav so the two cannot disagree. Enforced HERE and not only + * there, because the nav can only decline to offer a link — `?section=gallery` typed into the address + * bar, or a bookmark from before the connection was forgotten, arrives without going through it. A + * nav that hides a door is a courtesy; this is the lock. */ -type Status = { configured: boolean; lastProbeOk: boolean | null }; - /** * Which component renders which section. * @@ -50,39 +46,18 @@ export const DetailPanel = () => { const raw = params.get('section'); const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; - const client = useClient(); - const clientRef = useRef(client); - clientRef.current = client; + const { ready, loading } = useReady(); - const [configured, setConfigured] = useState(null); - const [signal] = useFofosChanged(); - - const check = useCallback(async () => { - try { - const status = await clientRef.current.get('/fofos-admin/status'); - setConfigured(status.configured); - } catch { - // Cannot tell. Render the section rather than the setup form — a transient failure must not look - // like "you have not set this up", which is a much more alarming thing to be told, and would - // invite somebody to re-enter a connection that was never the problem. - setConfigured(true); - } - }, []); - - useEffect(() => { - void check(); - }, [check, signal]); - - if (configured === null) { + if (loading) { return
Loading…
; } /* - Unconfigured, so Administration regardless of what the nav says. The header then NAMES it, which is - the part the overlay could not do: the form used to appear under whichever row happened to be - highlighted, so it read as that section being broken rather than as one step not done yet. + Not ready, so Administration regardless of what the URL asked for. The header then NAMES it, which + is the part the old overlay could not do: the form used to appear under whichever row happened to + be highlighted, so it read as that section being broken rather than as one step not done yet. */ - const current = sectionFor(configured ? selected : 'administration'); + const current = sectionFor(ready ? selected : 'administration'); const Panel = current.built ? PANELS[current.key] : undefined; diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 115c41d..f7992d0 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -1,5 +1,7 @@ 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. @@ -20,6 +22,15 @@ import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sect * `` rather than `` 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 `
`, dimmed, with no + * href to cmd-click and nothing to focus. Rendering a `` 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. */ /** @@ -40,22 +51,20 @@ 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); - return ( - + const body = ( + <> @@ -68,6 +77,26 @@ export const NavPanel = () => { {section.hint} + {locked && } + + ); + + if (locked) { + return ( +
+ {body} +
+ ); + } + + return ( + + {body} ); }; @@ -79,6 +108,9 @@ export const NavPanel = () => { {/* 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. */}
+ {/* 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 &&

Connect fofos below to open the rest.

} {SECTIONS.filter((section) => section.pinned).map(renderRow)}
diff --git a/web/useReady.ts b/web/useReady.ts new file mode 100644 index 0000000..58d0b0e --- /dev/null +++ b/web/useReady.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useClient } from 'hooks/useClient'; +import { useFofosChanged } from './channel'; + +/** + * Is this plugin ready to be USED — that is, may anything but Administration be opened? + * + * ── One condition, read in two places ── + * + * The nav decides whether a section is clickable and the detail panel decides what to render. Those + * are two answers to the same question, and a nav that lets you click a row the panel then refuses is + * worse than one that does not offer it. So the condition lives here and both panels read it, rather + * than each deciding for itself — the panels are siblings and neither may own the other. + * + * ── It is a GATE, not a hint ── + * + * Not ready means the site sections cannot be reached at all, by clicking or by typing `?section=` in + * the address bar. Every one of them is a window onto fofos, and without a working connection there is + * nothing behind any of them; offering nine doors onto the same "not connected" message taught nobody + * anything and made a setup step look like nine faults. + * + * ── Hardcoded false, deliberately, for now ── + * + * The shape is real and the plumbing is real: it asks `/status`, it re-asks whenever the connection + * changes, and it reports while it is still asking. Only the final verdict is pinned, so turning the + * gate on is one line rather than a feature. + * + * ── Both panels call this, so `/status` is fetched twice ── + * + * Known and accepted for now: two cheap GETs that answer the same question, kept in step because both + * re-run on the same `useFofosChanged` signal. The alternative is a context provider around the two + * panels, which is a shared-state boundary this does not yet need — and the duplication is visible + * here rather than hidden in a wrapper. If the condition grows expensive, that is the moment to fix + * it, not before. + * + * What it will become is roughly `configured && lastProbeOk` — the connection is stored AND the last + * probe reached fofos — but "all the info needed in Administration" is not settled yet and will grow + * past those two. That is why this returns a single boolean and not the parts: callers must not start + * assembling their own version of the condition out of the pieces. + */ + +type Status = { configured: boolean; lastProbeOk: boolean | null }; + +export type Readiness = { + /** True only when every section may be opened. */ + ready: boolean; + /** Still asking. Callers should render nothing rather than guess — see below. */ + loading: boolean; +}; + +export const useReady = (): Readiness => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [status, setStatus] = useState(null); + const [signal] = useFofosChanged(); + + const check = useCallback(async () => { + try { + setStatus(await clientRef.current.get('/fofos-admin/status')); + } catch { + /* + Cannot tell. Treated as CONFIGURED rather than not — a transient failure must never present as + "you have not set this up", which is a far more alarming thing to be told and invites somebody + to re-enter a connection that was never the problem. + */ + setStatus({ configured: true, lastProbeOk: null }); + } + }, []); + + useEffect(() => { + void check(); + }, [check, signal]); + + // ──────────────────────────────────────────────────────────────────────────────────────────────── + // The gate. Replace `false` with the real condition — nothing else here has to change. + // ──────────────────────────────────────────────────────────────────────────────────────────────── + const ready = false; + + return { ready, loading: status === null }; +};