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>
96 lines
4.4 KiB
TypeScript
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'));
|
|
},
|
|
},
|
|
];
|