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 * `/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(null); const [denied, setDenied] = useState(false); const load = useCallback(async () => { try { setSettings(await clientRef.current.get('/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 Only the owner can see where the site's media lives.; } if (!settings) { return Loading…; } /* 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 ( 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. ); } 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 ( { 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 ; }; const Notice = ({ icon: Icon, children }: { icon: typeof FolderOpen; children: React.ReactNode }) => (

{children}

);