Files
fofos-admin/web/MediaPanel.tsx
T
pastilhasandClaude Opus 5 3dd4dd6592 one header instead of three rows, and the folder lives in the URL
THE TOP WAS THREE ROWS OF FURNITURE before any content: the workspace's chrome
header saying "Detail", the panel's own strip saying "Media — Photos and video the
site is built from", and then the browser's toolbar.

a panel meta may supply its own `header`, which is the mechanism for exactly this and
which gitea, invoices and desktop already use. so the section's name goes where a
panel's name goes, and the strip inside is gone. "Detail" was never information —
the panel is the detail panel, and the useful word is which section is open.

the hint went with it. "Photos and video the site is built from" earns its place in
the NAV, where somebody is choosing between ten sections. repeated above the one they
chose, it is a caption for a decision already made.

AND THE FOLDER IS IN THE QUERY STRING. `urlPath` puts it in `?path=`, so a reload
lands where you were and a link can be sent to somebody — which for a media folder
somebody else uploads into is the point. it is opt-in in the platform because a
screen with two browsers would have them fighting over one parameter; this screen has
one.

`panelId` stays alongside it: the URL covers a reload or a shared link, and panelId
covers a panel re-opened later, which the URL cannot.

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

110 lines
4.8 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.
`urlPath` puts the current folder in `?path=`, so a reload lands where you were and a link can be
sent to somebody. it is opt-in in the platform because a screen with TWO browsers would have them
fighting over one parameter — this screen has one, so it is safe here and not in general.
`panelId` stays as well: it remembers the folder for a panel that is re-opened rather than reloaded,
which the URL cannot cover.
*/
return <FileBrowserApp basePath={base} rootLabel="Media" urlPath 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>
);