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:
+23
-1
@@ -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<SettingsView> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -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<Record<SectionKey, () => React.JSX.Element>> = {
|
||||
messages: MessagesPanel,
|
||||
administration: AdministrationPanel,
|
||||
media: MediaPanel,
|
||||
};
|
||||
|
||||
export const DetailPanel = () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user