Files
fofos-admin/web/file-actions.ts
T
pastilhasandClaude Opus 5 a7fc07c52f sections are paths, not a query string
The admin declares its routes in the manifest, so /fofos-admin/layout/header and
/fofos-admin/pages/home are real URLs. One reader takes the two segments after
the mount; links carry no query string at all, so nothing leaks between
sections.

What stays in the query is how you are looking — device, tab, view — rather than
at what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 04:57:29 +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 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, context) => {
/*
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') {
/*
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 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.
*/
// 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('galleries')}?${params.toString()}`);
// The router listens for this; pushState alone does not notify it.
window.dispatchEvent(new PopStateEvent('popstate'));
},
},
];