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 };
};