a Media section, browsing the site's originals in place

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 `<root>/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 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 08:39:53 +01:00
co-authored by Claude Opus 5
parent 9da0c31a90
commit 944d3d9c5b
5 changed files with 155 additions and 3 deletions
+23 -1
View File
@@ -29,6 +29,18 @@ import { fofosAdminSettings } from '../db/schema';
export type SettingsView = { export type SettingsView = {
/** Absolute. Never null — an unset root reports its default rather than nothing. */ /** Absolute. Never null — an unset root reports its default rather than nothing. */
rootPath: string; 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. */ /** False while the default is standing in, so a form can show it as a suggestion, not a choice. */
isSet: boolean; isSet: boolean;
}; };
@@ -44,7 +56,17 @@ export const defaultRootPath = (email: string): string => join(getOwnerHomeDir(e
export async function readSettings(email: string): Promise<SettingsView> { export async function readSettings(email: string): Promise<SettingsView> {
const found = await row(); const found = await row();
const stored = found?.rootPath ?? null; 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,
};
} }
/** /**
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
version: '2026.08.29.18', version: '2026.08.29.19',
platform: '>=1.0.0', platform: '>=1.0.0',
label: 'Fofos', label: 'Fofos',
+2
View File
@@ -1,6 +1,7 @@
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections'; import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel'; import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel';
import { MessagesPanel } from './MessagesPanel'; import { MessagesPanel } from './MessagesPanel';
import { PlannedSection } from './PlannedSection'; import { PlannedSection } from './PlannedSection';
import { useReady } from './useReady'; import { useReady } from './useReady';
@@ -39,6 +40,7 @@ import { useReady } from './useReady';
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = { const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
messages: MessagesPanel, messages: MessagesPanel,
administration: AdministrationPanel, administration: AdministrationPanel,
media: MediaPanel,
}; };
export const DetailPanel = () => { export const DetailPanel = () => {
+108
View File
@@ -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
* `<root>/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<Settings | null>(null);
const [denied, setDenied] = useState(false);
const load = useCallback(async () => {
try {
setSettings(await clientRef.current.get<Settings>('/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 <Notice icon={TriangleAlert}>Only the owner can see where the site's media lives.</Notice>;
}
if (!settings) {
return <Notice icon={FolderOpen}>Loading…</Notice>;
}
/*
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 (
<Notice icon={TriangleAlert}>
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.
</Notice>
);
}
const base = `${settings.rootRelative === '/' ? '' : settings.rootRelative}/${ORIGINALS}`;
return (
<div className="flex size-full flex-col overflow-hidden">
{/* 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. */}
<p className="shrink-0 border-b border-border px-4 py-2 text-xs text-muted-foreground">
Uploads live here. The converted copies in <code>derived/</code> are made from these and are not edited by hand.
</p>
<div className="min-h-0 flex-1">
{/*
`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.
*/}
<FileBrowserApp basePath={base} panelId="fofos-admin-media" />
</div>
</div>
);
};
const Notice = ({ icon: Icon, children }: { icon: typeof FolderOpen; children: React.ReactNode }) => (
<div className="flex size-full flex-col items-center justify-center gap-2 p-6 text-center">
<Icon className="size-6 text-muted-foreground/60" />
<p className="max-w-sm text-sm text-muted-foreground">{children}</p>
</div>
);
+21 -1
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react'; 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. * 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 = export type SectionKey =
| 'administration' | 'administration'
| 'media'
| 'messages' | 'messages'
| 'site' | 'site'
| 'about' | 'about'
@@ -132,6 +133,25 @@ export const SECTIONS: Section[] = [
path: '/fofos-admin/messages', path: '/fofos-admin/messages',
built: true, 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 Last, and `path: null` — it reads nothing from fofos. Every other section is a window onto the