diff --git a/api/router.ts b/api/router.ts index d0f7916..b7cc36a 100644 --- a/api/router.ts +++ b/api/router.ts @@ -235,8 +235,22 @@ router.post('/_config/media/create', async (ctx) => { * section or a "not connected yet" message, and that answer contains no credential. */ router.get('/status', async (ctx) => { + const user = ctx.get('user'); const connection = await readConnection(); - return ctx.json({ configured: connection.configured, lastProbeOk: connection.lastProbeOk }); + const settings = await readSettings(user.email); + + /* + `rootSet` is a BOOLEAN and this route is deliberately not owner-only. + + The nav has to know whether Media can be opened, and every account that can see the section needs + that answer. The path itself stays behind `/_config`, which is owner-only — this says only whether + one has been chosen, which reveals nothing about the machine. + */ + return ctx.json({ + configured: connection.configured, + lastProbeOk: connection.lastProbeOk, + rootSet: settings.isSet, + }); }); /** diff --git a/manifest.ts b/manifest.ts index 1bf7634..cf8a7a1 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.19', + version: '2026.08.29.20', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 63c3150..5219b27 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,5 +1,5 @@ import { useSearchParams } from 'react-router'; -import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections'; +import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { MessagesPanel } from './MessagesPanel'; @@ -48,7 +48,7 @@ export const DetailPanel = () => { const raw = params.get('section'); const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; - const { ready, loading } = useReady(); + const { ready, rootSet, loading } = useReady(); if (loading) { return
Loading…
; @@ -59,7 +59,13 @@ export const DetailPanel = () => { 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(ready ? selected : 'administration'); + /* + A section that cannot be opened sends you to Administration, because that is where every requirement + is satisfied. Asked per section rather than by one flag: Media is reachable the moment a root + location exists, long before fofos is connected. + */ + const wanted = sectionFor(selected); + const current = sectionOpen(wanted, { ready, rootSet }) ? wanted : sectionFor('administration'); const Panel = current.built ? PANELS[current.key] : undefined; diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index f7992d0..626c638 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -1,6 +1,6 @@ import { Link, useSearchParams } from 'react-router'; import { Lock } from 'lucide-react'; -import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections'; +import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections'; import { useReady } from './useReady'; /** @@ -51,12 +51,13 @@ export const NavPanel = () => { const [params] = useSearchParams(); const raw = params.get('section'); const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; - const { ready } = useReady(); + const { ready, rootSet } = 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; + // Each section asks its own question now — Media wants a root location, the site's pages want a + // connection, Administration wants nothing because it is the way out of both. + const locked = !sectionOpen(section, { ready, rootSet }); // 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. @@ -110,7 +111,11 @@ export const NavPanel = () => {
{/* 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.

} + {!ready && ( +

+ {rootSet ? 'Connect fofos below to open the rest.' : 'Set a root location below to begin.'} +

+ )} {SECTIONS.filter((section) => section.pinned).map(renderRow)}
diff --git a/web/sections.ts b/web/sections.ts index 4ab1a0a..5a1291e 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -49,6 +49,18 @@ export type Section = { /** The fofos path this reads, relative to the plugin's mount. Null when nothing is built. */ path: string | null; built: boolean; + /** + * What has to be true before this section can be opened. + * + * 'connection' the fofos API must be reachable — every section that READS the site + * 'root' only the root location must be set — the file browser needs nowhere to talk to + * 'nothing' always open, because it is where you go to satisfy the other two + * + * This exists because the sections do not all depend on the same thing, and treating them as if they + * did was wrong in a way that showed: Media needs a folder on this machine and nothing else, and was + * locked behind a connection it never uses. + */ + requires: 'connection' | 'root' | 'nothing'; /** * Pinned to the FOOT of the nav rather than sitting in the list. * @@ -68,6 +80,7 @@ export const SECTIONS: Section[] = [ hint: 'The hero, the intro, and the gallery', path: '/fofos-admin/home', built: false, + requires: 'connection', }, { key: 'about', @@ -76,6 +89,7 @@ export const SECTIONS: Section[] = [ hint: 'The header photograph and the copy', path: '/fofos-admin/about', built: false, + requires: 'connection', }, { key: 'collections', @@ -84,6 +98,7 @@ export const SECTIONS: Section[] = [ hint: 'Collections, and the items in them', path: '/fofos-admin/collections', built: false, + requires: 'connection', }, { key: 'gallery', @@ -92,6 +107,7 @@ export const SECTIONS: Section[] = [ hint: 'Standalone pieces', path: '/fofos-admin/gallery', built: false, + requires: 'connection', }, { key: 'collabs', @@ -100,6 +116,7 @@ export const SECTIONS: Section[] = [ hint: 'Projects with other people', path: '/fofos-admin/collabs', built: false, + requires: 'connection', }, { key: 'drops', @@ -108,6 +125,7 @@ export const SECTIONS: Section[] = [ hint: 'Releases', path: '/fofos-admin/drops', built: false, + requires: 'connection', }, { key: 'services', @@ -116,6 +134,7 @@ export const SECTIONS: Section[] = [ hint: 'A fixed tree — the words, not the shape', path: '/fofos-admin/services', built: false, + requires: 'connection', }, { key: 'site', @@ -124,6 +143,7 @@ export const SECTIONS: Section[] = [ hint: 'Partners, social links, contact details', path: '/fofos-admin/site', built: false, + requires: 'connection', }, { key: 'messages', @@ -132,6 +152,7 @@ export const SECTIONS: Section[] = [ hint: 'What people sent through the contact form', path: '/fofos-admin/messages', built: true, + requires: 'connection', }, { /* @@ -151,6 +172,10 @@ export const SECTIONS: Section[] = [ hint: 'Photos and video the site is built from', path: null, built: true, + // A folder on this machine. It never speaks to fofos, so a connection is not its business. + requires: 'root', + // Pinned beside Administration: both are about this machine rather than about the site's pages. + pinned: true, }, { /* @@ -164,6 +189,9 @@ export const SECTIONS: Section[] = [ hint: 'Where fofos is, and the token to reach it', path: null, built: true, + // Always reachable. It is where the other two requirements are satisfied, so locking it would be + // locking somebody out of the only door. + requires: 'nothing', pinned: true, }, ]; @@ -176,3 +204,25 @@ export const isSectionKey = (value: string | null): value is SectionKey => export const sectionFor = (key: SectionKey): Section => SECTIONS.find((section) => section.key === key) ?? SECTIONS[SECTIONS.length - 1]!; + +/** + * Can this section be opened, given what is currently true? + * + * One function, used by the nav to decide whether to offer a link and by the detail panel to decide + * what to render — so the two cannot disagree about a section. That was already the rule when there was + * one global flag; it matters more now that each section asks its own question. + */ +export const sectionOpen = (section: Section, state: { ready: boolean; rootSet: boolean }): boolean => { + switch (section.requires) { + case 'nothing': + return true; + case 'root': + return state.rootSet; + case 'connection': + return state.ready; + } +}; + +/** Why it is locked, in the fewest words that are actually useful. */ +export const sectionBlockedBy = (section: Section): string => + section.requires === 'root' ? 'needs a root location' : 'needs a connection to fofos'; diff --git a/web/useReady.ts b/web/useReady.ts index 58d0b0e..7ff5fed 100644 --- a/web/useReady.ts +++ b/web/useReady.ts @@ -39,11 +39,19 @@ import { useFofosChanged } from './channel'; * assembling their own version of the condition out of the pieces. */ -type Status = { configured: boolean; lastProbeOk: boolean | null }; +type Status = { configured: boolean; lastProbeOk: boolean | null; rootSet: boolean }; export type Readiness = { - /** True only when every section may be opened. */ + /** The fofos connection works. Gates every section that READS the site. */ ready: boolean; + /** + * A root location has been chosen. + * + * Separate from `ready` because the two are genuinely different questions and one section depends on + * each. Media needs a folder on this machine; it never speaks to fofos, and gating it behind a + * connection it does not use was the bug that made this distinction necessary. + */ + rootSet: boolean; /** Still asking. Callers should render nothing rather than guess — see below. */ loading: boolean; }; @@ -65,7 +73,7 @@ export const useReady = (): Readiness => { "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 }); + setStatus({ configured: true, lastProbeOk: null, rootSet: false }); } }, []); @@ -78,5 +86,5 @@ export const useReady = (): Readiness => { // ──────────────────────────────────────────────────────────────────────────────────────────────── const ready = false; - return { ready, loading: status === null }; + return { ready, rootSet: status?.rootSet ?? false, loading: status === null }; };