Files
fofos-admin/web/MediaPanel.tsx
T
pastilhasandClaude Opus 5 21935cbc27 the media folder lives in ?media=, not in the route
`urlPath` sent the whole app to /files the moment anybody opened a folder — it owns
the pathname, which is right for the Files screen and wrong for a panel. it uses the
platform's new `pathParam` instead, which names a search param and leaves the route
alone.

`?media=` rather than `?path=`: this screen already carries `?section=` and may one
day carry a second browser. a param named after what it addresses cannot collide
with one named after a concept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:01:41 +01:00

114 lines
5.0 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}`;
/*
Just the browser. No banner explaining `derived/`.
The people using this upload photographs; the two-folder split is the machine's business, not
theirs. A line at the top of every visit is a permanent tax paid by everyone to answer a question
almost nobody asks — and it is written properly in the affordance and the plugin's docs.
`rootLabel` so the breadcrumb reads "Media" rather than "originals": the folder's name on disk is
an implementation detail of how this plugin lays out a root, and the section is called Media.
`pathParam` keeps the folder in `?media=`, so a reload lands where you were and the link can be sent
to somebody. NOT `urlPath`, which was the first attempt and was wrong: that one owns the URL's
PATHNAME and navigates to /files, so opening a folder here took the whole app to the Files screen.
`?media=` rather than `?path=` because this screen already carries `?section=` and may one day carry
another browser; a param named after what it addresses cannot collide with one named after a
concept.
`panelId` stays alongside: the URL covers a reload or a shared link, panelId covers a panel
re-opened later, which the URL cannot.
*/
return <FileBrowserApp basePath={base} rootLabel="Media" pathParam="media" panelId="fofos-admin-media" />;
};
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>
);