Files
fofos-admin/web/MediaPanel.tsx
T
pastilhasandClaude Opus 5 1b171c3335 the media preview steps through the folder
passes `onNavigate` to the viewer, which turns on the platform's new carousel:
arrows, a thumbnail strip and left/right/escape.

`?view=` is an absolute path in this panel, so the step is written straight back —
the name-relative-to-folder conversion the Files screen does is that screen's own
convention rather than a rule the viewer imposes.

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

157 lines
6.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { FolderOpen, TriangleAlert } from 'lucide-react';
import { FileBrowserApp, FileViewerView } 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 [params, setParams] = useSearchParams();
// Set by the browser when a file is double-clicked. Nothing else on this screen reads it.
const viewPath = params.get('view');
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.
*/
/*
── The preview, which needed doing here rather than coming for free ──
On /files the viewer is not part of the browser: `useFileViewerPanels` lives on the SCREEN and swaps
the whole workspace layout when `?view=` appears, opening a viewer panel beside it. A plugin renders
its own `defaultLayout` and has no such hook, so double-clicking a file here set `?view=` and nothing
ever read it. It looked like it worked only while `urlPath` was navigating to /files, where that
screen did the work.
So the panel reads `?view=` itself and shows the viewer over the browser. Over rather than beside,
because this panel is one column of a two-column screen — splitting it again would leave a preview
the width of a phone.
Closing clears `view` and leaves `media` alone, so you come back to the folder you were in rather
than the top.
*/
if (viewPath) {
return (
<FileViewerView
filePath={viewPath}
fileName={viewPath.slice(viewPath.lastIndexOf('/') + 1)}
/* The carousel's step. `?view=` is an absolute path in this panel, so it is written straight
back — the conversion the Files screen needs is that screen's own convention, not a rule. */
onNavigate={(next) => {
const params2 = new URLSearchParams(params);
params2.set('view', next);
setParams(params2);
}}
onClose={() => {
const next = new URLSearchParams(params);
next.delete('view');
next.delete('viewRoot');
setParams(next);
}}
/>
);
}
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>
);