From c38d269faf84e91bc7497084b695e22f72f4cf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 08:44:11 +0100 Subject: [PATCH] Media moves to the foot, and each section asks its own question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO REPORTS, AND THE SECOND ONE WAS A DESIGN MISTAKE RATHER THAN A SETTING. Media is pinned now, beside Administration. it sat at the end of the scrolling list, which is the bottom of the LIST and not the bottom of the panel. it belongs in the foot for the same reason Administration does: both are about this machine, while everything above them is about the site's pages. and it was LOCKED BEHIND A CONNECTION IT NEVER USES. `ready` was one global flag — "can anything be opened" — and Media is a folder on this disk that never speaks to fofos. so sections now declare what they need: 'connection' the site's pages, which read fofos 'root' Media, which needs a folder and nothing else 'nothing' Administration, because locking the door you fix things through is locking somebody out entirely `sectionOpen` is 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 with one flag; it matters more now that each section asks a different question. `/status` gained `rootSet`, a BOOLEAN, and stays not-owner-only. the nav needs the answer for every account that can see the section; the path itself stays behind `/_config`, which is owner-only. whether a root has been chosen reveals nothing about the machine — where it is would. the empty-state line follows too: "set a root location below to begin" before there is one, "connect fofos to open the rest" after — rather than one sentence naming a step that is not the next one. Co-Authored-By: Claude Opus 5 --- api/router.ts | 16 ++++++++++++++- manifest.ts | 2 +- web/DetailPanel.tsx | 12 ++++++++--- web/NavPanel.tsx | 15 +++++++++----- web/sections.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ web/useReady.ts | 16 +++++++++++---- 6 files changed, 97 insertions(+), 14 deletions(-) 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 }; };