From 944d3d9c5b43142ad47a1777a811a88ddc973c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 08:39:53 +0100 Subject: [PATCH] a Media section, browsing the site's originals in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a new section above Administration. it embeds THE PLATFORM'S OWN file browser rather than drawing a file list — upload, delete, rename, move, thumbnails, the context menu and every task hanging off it already exist and are maintained in one place. a plugin's own would be a second to keep in step, arriving without any of that. `basePath` IS A JAIL, not a starting folder: `useFileBrowserApp` resets currentPath whenever it leaves, so pointing it at `/media/originals` means the breadcrumb stops there and `derived/` is not reachable by navigating. that is what was asked for — people work in originals, derived is made for them. IT IS A GUARDRAIL AND NOT A PERMISSION BOUNDARY, and the panel says so in its own docstring because the difference will matter to whoever reads this next. the file-browser API is unchanged and still addresses the whole home: anyone with file access reaches derived/ through the Files screen. this prevents an accident, not a determined person. confining it properly means scoping that API per user, which is a platform change and a much larger one. the panel also says, once, that derived/ exists and is made from these — otherwise somebody who knows about it would think it had been lost. `rootRelative` is new on the settings API because only the SERVER knows what home is. the file browser addresses everything relative to a root, and a browser deriving that prefix from the absolute path would be guessing. null when the root sits outside home — a legitimate setting the browser cannot address, which the panel explains rather than rendering an empty folder. NOTHING HERE IS FOFOS-SPECIFIC, which matters now that this plugin is the template for other site admins: the root is a setting, media/originals is the layout this plugin creates, and both are read from the API. another site's plugin changes the label. Co-Authored-By: Claude Opus 5 --- api/settings.ts | 24 +++++++++- manifest.ts | 2 +- web/DetailPanel.tsx | 2 + web/MediaPanel.tsx | 108 ++++++++++++++++++++++++++++++++++++++++++++ web/sections.ts | 22 ++++++++- 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 web/MediaPanel.tsx diff --git a/api/settings.ts b/api/settings.ts index f77c365..4d9c27d 100644 --- a/api/settings.ts +++ b/api/settings.ts @@ -29,6 +29,18 @@ import { fofosAdminSettings } from '../db/schema'; export type SettingsView = { /** Absolute. Never null — an unset root reports its default rather than nothing. */ rootPath: string; + /** + * The same place, relative to the owner's home, with a leading slash. + * + * The file browser addresses everything relative to a root — `home` — so a panel embedding it needs + * this form and cannot work it out: only the server knows what home is, and a browser computing it + * from the absolute path would be guessing at a prefix. + * + * Null when the root is outside home. That is a legitimate configuration — the root may be anywhere + * absolute — and it means the embedded browser cannot show it, which the panel says rather than + * rendering an empty folder. + */ + rootRelative: string | null; /** False while the default is standing in, so a form can show it as a suggestion, not a choice. */ isSet: boolean; }; @@ -44,7 +56,17 @@ export const defaultRootPath = (email: string): string => join(getOwnerHomeDir(e export async function readSettings(email: string): Promise { const found = await row(); const stored = found?.rootPath ?? null; - return { rootPath: stored ?? defaultRootPath(email), isSet: stored !== null }; + const rootPath = stored ?? defaultRootPath(email); + + // `home` or a path beneath it — anything else is outside what the file browser can address. + const home = getOwnerHomeDir(email); + const inside = rootPath === home || rootPath.startsWith(`${home}/`); + + return { + rootPath, + rootRelative: inside ? `/${rootPath.slice(home.length).replace(/^\/+/, '')}`.replace(/\/$/, '') || '/' : null, + isSet: stored !== null, + }; } /** diff --git a/manifest.ts b/manifest.ts index 7f15c85..1bf7634 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.18', + version: '2026.08.29.19', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 6fc712d..63c3150 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,6 +1,7 @@ import { useSearchParams } from 'react-router'; import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; +import { MediaPanel } from './MediaPanel'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; import { useReady } from './useReady'; @@ -39,6 +40,7 @@ import { useReady } from './useReady'; const PANELS: Partial React.JSX.Element>> = { messages: MessagesPanel, administration: AdministrationPanel, + media: MediaPanel, }; export const DetailPanel = () => { diff --git a/web/MediaPanel.tsx b/web/MediaPanel.tsx new file mode 100644 index 0000000..525499f --- /dev/null +++ b/web/MediaPanel.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FolderOpen, TriangleAlert } from 'lucide-react'; +import { FileBrowserApp } from 'officerdev'; +import { useClient } from 'hooks/useClient'; + +/** + * The site's own media, browsed in place. + * + * ── It embeds the platform's file browser rather than reimplementing one ── + * + * Upload, delete, rename, move, thumbnails, the context menu and every task that hangs off it already + * exist and are maintained in one place. A plugin drawing its own file list would be a second one to + * keep in step, and it would arrive without any of that. + * + * ── `basePath` is a jail, not a starting folder ── + * + * `useFileBrowserApp` resets `currentPath` whenever it leaves `basePath`, so pointing it at + * `/media/originals` means the breadcrumb stops there and `derived/` is not reachable by + * navigating. That is the behaviour the owner asked for: people work in originals, and derived is made + * for them. + * + * **It is a guardrail, not a permission boundary, and the difference matters.** The file-browser API is + * unchanged and still addresses the whole home — somebody with file access can reach `derived/` through + * the Files screen or by hand. Confining it properly would mean scoping the API per user, which is a + * platform change and a much larger one. What this prevents is an accident, not a determined person. + * + * ── Nothing here is fofos-specific ── + * + * The root is a setting, `media/originals` is the layout this plugin creates, and the panel reads both + * from the API. Another site's admin plugin built from this one changes the label and nothing else. + */ + +type Settings = { rootPath: string; rootRelative: string | null; isSet: boolean }; + +/** Where the uploads live, relative to the root. The other half, `derived/`, is deliberately not shown. */ +const ORIGINALS = 'media/originals'; + +export const MediaPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [settings, setSettings] = useState(null); + const [denied, setDenied] = useState(false); + + const load = useCallback(async () => { + try { + setSettings(await clientRef.current.get('/fofos-admin/_config/root')); + } catch (failure) { + // The root is owner-only. A member sees the section and is told why rather than an empty browser. + if ((failure as { status?: number })?.status === 403) setDenied(true); + else setSettings({ rootPath: '', rootRelative: null, isSet: false }); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (denied) { + return Only the owner can see where the site's media lives.; + } + + if (!settings) { + return Loading…; + } + + /* + A root outside the owner's home is a legitimate setting and the file browser cannot address it — + everything it does is relative to `home`. Said plainly here rather than rendering an empty folder, + which would read as "there is nothing in your media". + */ + if (!settings.rootRelative) { + return ( + + The root location is outside your home directory, so the file browser cannot show it. Move it under your home in + Administration, or use the Files screen. + + ); + } + + const base = `${settings.rootRelative === '/' ? '' : settings.rootRelative}/${ORIGINALS}`; + + return ( +
+ {/* Said once, at the top, because the whole point of this panel is that it shows ONE of the two + folders. Without it, somebody who knows `derived/` exists would think it had been lost. */} +

+ Uploads live here. The converted copies in derived/ are made from these and are not edited by hand. +

+ +
+ {/* + `panelId` so the folder somebody is in survives a reload — without it every refresh drops them + back at the top of originals, however deep they were. + */} + +
+
+ ); +}; + +const Notice = ({ icon: Icon, children }: { icon: typeof FolderOpen; children: React.ReactNode }) => ( +
+ +

{children}

+
+); diff --git a/web/sections.ts b/web/sections.ts index d76f933..4ab1a0a 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog } from 'lucide-react'; +import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog, FolderOpen } from 'lucide-react'; /** * The sections of the fofos admin, in nav order. @@ -29,6 +29,7 @@ import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail export type SectionKey = | 'administration' + | 'media' | 'messages' | 'site' | 'about' @@ -132,6 +133,25 @@ export const SECTIONS: Section[] = [ path: '/fofos-admin/messages', built: true, }, + { + /* + The site's own media, browsed in place. + + Above Administration and below the pages, because it is neither: the pages are the site's CONTENT + and Administration is this plugin's machinery, while this is the raw material both draw on. It is + also the section somebody uses most often once the site exists, which is the other reason it sits + where a hand falls rather than at the bottom. + + `path: null` — it reads no fofos API. It embeds the platform's own file browser, confined to the + originals folder under the root location. + */ + key: 'media', + label: 'Media', + icon: FolderOpen, + hint: 'Photos and video the site is built from', + path: null, + built: true, + }, { /* Last, and `path: null` — it reads nothing from fofos. Every other section is a window onto the