Files
fofos-admin/web/file-actions.ts
pastilhasandClaude Opus 5 6e33e89e2c finish the carousel rename in the panels
Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:06:06 +01:00

96 lines
4.4 KiB
TypeScript

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'));
},
},
];