import { Images } from 'lucide-react'; import { href } from './routes'; import type { FileAction } from 'officerdev'; /** * What this plugin adds to the file browser's context menu. * * ── Why an action and not a task ── * * A task runs a script as the owner and reports through the job system, which is right for "convert * these" and wrong for this. Creating a carousel is a name to type and a screen to land on — expressing * it as a job would produce something to watch whose only output is somewhere to go next. * * ── It only offers itself inside `originals/` ── * * A carousel item is a path relative to that folder, so the action is meaningless anywhere else — and a * menu entry that appears everywhere and works in one place is worse than one that appears where it * works. The check is on the path rather than on which panel is open, because the same browser is * mounted in several places and only the path says where it is pointed. */ const ORIGINALS = '/media/originals'; /** Everything under `originals/`, relative to it — which is what a carousel item stores. */ const relativeTo = (path: string): string | null => { const at = path.indexOf(ORIGINALS); return at === -1 ? null : path.slice(at + ORIGINALS.length).replace(/^\/+/, ''); }; export const fileActions: FileAction[] = [ { key: 'fofos-admin:create-carousel', label: 'Carousel from folder', icon: Images, /* A folder, or any number of files — both are ways of saying "these, in this order". A mixed selection is refused: a folder inside a selection of files is either a mistake or a request to recurse, and neither is something to guess at from a right-click. */ appliesTo: (entries) => { if (!entries.length) return false; if (!entries.every((entry) => relativeTo(entry.path))) return false; const directories = entries.filter((entry) => entry.type === 'directory').length; return directories === 0 || (directories === 1 && entries.length === 1); }, run: async (entries, context) => { /* A folder is expanded into the files inside it. What somebody right-clicked is the directory; what a carousel holds is pictures. Without this the carousel gets one item — the folder — which is what shipped first and is obviously wrong the moment you look at it. Expanded by the SERVER, because that side already knows which extensions are media and how to walk the tree: it is the same `listOriginals` the sync uses, so a carousel cannot end up holding a file the pipeline would never convert. */ const chosen = entries.map((entry) => relativeTo(entry.path)).filter((path): path is string => Boolean(path)); let paths = chosen; if (entries.length === 1 && entries[0]!.type === 'directory') { /* The context's client, not `fetch`. A bare fetch returns 401: this API authenticates with a bearer token rather than a cookie, and only the hook holds it — which `run` cannot call, being an event handler outside React. So the browser hands one over. */ const body = await context.client .get<{ files?: string[] }>(`/fofos-admin/_config/media/files?path=${encodeURIComponent(chosen[0] ?? '')}`) .catch(() => null); // Falling back to the folder itself would create the wrong carousel quietly. Nothing is better. if (!body?.files?.length) return; paths = body.files; } /* Handed over in the URL, and the Carousels panel does the asking. The action has no dialog of its own on purpose: naming a carousel, seeing the slug it will get and watching the items land belongs on the screen that owns carousels, not in a modal over a file browser that would then have to explain where the result went. */ // The section is a PATH now, so this names it rather than editing the current query. `create` and // `from` stay in the query: they are a one-shot handover, not where you are. const params = new URLSearchParams(); params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : ''); params.set('from', paths.join('\n')); window.history.pushState({}, '', `${href('carousels')}?${params.toString()}`); // The router listens for this; pushState alone does not notify it. window.dispatchEvent(new PopStateEvent('popstate')); }, }, ];