Files
fofos-admin/web/MediaPanel.tsx
T
pastilhasandClaude Opus 5 944d3d9c5b 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>
2026-08-29 08:39:53 +01:00

109 lines
4.6 KiB
TypeScript

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