diff --git a/api/router.ts b/api/router.ts index 3ca0b6c..d0e0ef8 100644 --- a/api/router.ts +++ b/api/router.ts @@ -7,7 +7,7 @@ import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website'; import { readMediaStatus, createMediaDir } from './media'; import { readSync, startSync } from './sync'; import { readRuns } from './ledger'; -import { reconcile } from './derive'; +import { listOriginals, originalsDir, reconcile, safeScope } from './derive'; import { createDatabases, pushSchema, readDatabases } from './databases'; import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups'; import { watchMailbox } from './messages'; @@ -250,6 +250,30 @@ router.post('/_config/media/create', async (ctx) => { * "Already running" is not a failure to report to somebody who pressed Sync; the honest answer is the * run that is in progress, which is what comes back. */ +/** + * The media files under a folder in `originals/`, relative to it. + * + * For "Create Gallery" on a directory: what somebody right-clicked is the folder, and what a gallery + * holds is the files. Expanded here rather than in the browser because this side already knows which + * extensions are media and how to walk the tree — `listOriginals` is the same function the sync uses, + * so a gallery cannot contain a file the pipeline would not convert. + * + * Recursive, deliberately: a folder of folders is still "these pictures", and refusing to descend would + * make the action silently miss most of what somebody pointed at. + */ +router.get('/_config/media/files', async (ctx) => { + const user = ctx.get('user'); + const scope = ctx.req.query('path') ?? ''; + try { + const root = await originalsDir(user.email); + const files = await listOriginals(root, safeScope(scope)); + // Prefixed back to the scope, so what comes out is what a gallery item stores. + return ctx.json({ files: files.map((file) => (scope ? `${safeScope(scope)}/${file}` : file)) }); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.post('/_config/media/sync', async (ctx) => { const user = ctx.get('user'); // A folder inside `originals/`, or nothing for all of it. Named `path` because that is what the file diff --git a/web/file-actions.ts b/web/file-actions.ts index b49e3cc..39b3d0d 100644 --- a/web/file-actions.ts +++ b/web/file-actions.ts @@ -43,8 +43,32 @@ export const fileActions: FileAction[] = [ const directories = entries.filter((entry) => entry.type === 'directory').length; return directories === 0 || (directories === 1 && entries.length === 1); }, - run: (entries) => { - const paths = entries.map((entry) => relativeTo(entry.path)).filter((path): path is string => Boolean(path)); + 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.