Files
fofos-admin/web/file-actions.ts
T
pastilhasandClaude Opus 5 a610f93189 Create Gallery on a folder means the files inside it
It stored the folder. What somebody right-clicks is a directory; what a gallery
holds is pictures — obvious the moment you look at the result, and not before.

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 gallery cannot end up holding a file the pipeline would never convert.

Recursive, deliberately: a folder of folders is still "these pictures", and
refusing to descend would silently miss most of what was pointed at.

An expansion that returns nothing creates nothing. Falling back to the folder
itself is exactly the bug being fixed, and doing it quietly is worse than doing
nothing.

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

89 lines
4.1 KiB
TypeScript

import { Images } from 'lucide-react';
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 gallery 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 gallery 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 gallery 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-gallery',
label: 'Create Gallery',
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) => {
/*
A folder is expanded into the files inside it.
What somebody right-clicked is the directory; what a gallery holds is pictures. Without this the
gallery 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 gallery 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') {
const response = await fetch(
`/api/fofos-admin/_config/media/files?path=${encodeURIComponent(chosen[0] ?? '')}`,
{ credentials: 'same-origin' },
).catch(() => null);
const body = response?.ok ? ((await response.json()) as { files?: string[] }) : null;
// Falling back to the folder itself would create the wrong gallery quietly. Nothing is better.
if (!body?.files?.length) return;
paths = body.files;
}
/*
Handed over in the URL, and the Galleries panel does the asking.
The action has no dialog of its own on purpose: naming a gallery, seeing the slug it will get
and watching the items land belongs on the screen that owns galleries, not in a modal over a
file browser that would then have to explain where the result went.
*/
const params = new URLSearchParams(window.location.search);
params.set('section', 'galleries');
params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : '');
params.set('from', paths.join('\n'));
window.history.pushState({}, '', `${window.location.pathname}?${params.toString()}`);
// The router listens for this; pushState alone does not notify it.
window.dispatchEvent(new PopStateEvent('popstate'));
},
},
];