diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index b16db8c..fd95d52 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -2,11 +2,11 @@ import { recipeFile } from './recipeFile'; import { sendVerificationMail } from './mailer'; import { placeName } from './place'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; -import { spawn, spawnSync } from 'node:child_process'; +import { exec, spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { DATA_DIR, MAX_PHOTO_BYTES, @@ -420,6 +420,30 @@ async function lookupGeo(ip: string): Promise { // ---- routes ---- app.get('/api/health', async () => ({ ok: true })); +// Native file manager launcher (Open in window explorer) +app.post('/api/open-explorer', async (req, reply) => { + const b = bodyOf(req); + if (!b || typeof b.path !== 'string') return reply.status(400).send({ ok: false, error: 'Invalid path' }); + const rawPath = b.path.trim(); + if (!rawPath) return reply.status(400).send({ ok: false, error: 'Path empty' }); + + const platform = process.platform; + try { + if (platform === 'win32') { + const winPath = rawPath.replace(/\//g, '\\'); + exec(`explorer.exe /select,"${winPath}"`); + } else if (platform === 'darwin') { + exec(`open -R "${rawPath}"`); + } else { + const dir = dirname(rawPath); + exec(`xdg-open "${dir}"`); + } + return reply.send({ ok: true }); + } catch (err: any) { + return reply.status(500).send({ ok: false, error: String(err?.message || err) }); + } +}); + // The counter's one write. Public and unauthenticated by design: it is fired by // a beacon from every page, so it must never be a way to probe who is signed in // — it answers 204 whatever happens and echoes nothing back. diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index bc28655..d6da93e 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -2094,18 +2094,20 @@ function Catalogue() { {menu.folder ? ( ready.has(menu.folder.name) ? ( <> - + {!menu.root ? ( + + ) : null} {/* Three things can be asked of a roll: bring it up to date, read it from the top again, or leave it — and the last only of the reading that is this roll's, or of the request for it that is @@ -2271,11 +2273,19 @@ function Catalogue() { const folderPath = photo.dir ? `${photo.folder}/${photo.dir}` : photo.folder; const fullPath = `${folderPath}/${photo.name}`; try { + const res = await api.openExplorer(fullPath).catch(() => null); + if (res?.ok) { + setNote(t('lib.scanned', { folder: fullPath, added: 0, total: 1 })); + } else if (navigator.clipboard) { + await navigator.clipboard.writeText(fullPath); + setNote(t('lib.copiedPath', { path: fullPath })); + } + } catch { if (navigator.clipboard) { await navigator.clipboard.writeText(fullPath); setNote(t('lib.copiedPath', { path: fullPath })); } - } catch {} + } }} > @@ -2300,6 +2310,14 @@ function Catalogue() { } return next; }); + setTimeout(() => { + const el = document.querySelector(`[data-key="lib-folder-${targetNode}"]`) || document.querySelector(`[data-key="lib-folder-${photo.folder}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('lib-folder-highlight'); + setTimeout(() => el.classList.remove('lib-folder-highlight'), 2000); + } + }, 120); }} > @@ -2385,7 +2403,7 @@ function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick // One object URL per tile, made when the row does and dropped with it: a folder const globalThumbUrls = new Map(); -const GLOBAL_URL_MAX = 2500; +const GLOBAL_URL_MAX = 600; function cacheObjectUrl(id: string, blob: Blob): string { const existing = globalThumbUrls.get(id); diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 85a09b3..5795ed8 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -353,4 +353,7 @@ export const api = { if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); return body as unknown as { user: User }; }, + + // Open photo location in native system file manager (Windows Explorer / Finder / Linux file manager) + openExplorer: (path: string) => call<{ ok: boolean }>('/open-explorer', { method: 'POST', body: JSON.stringify({ path }) }), }; diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 04ba790..4f9e37d 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -146,7 +146,7 @@ export function canBrowseFolders(): boolean { return typeof window !== 'undefined' && 'showDirectoryPicker' in window; } -const THUMB_CACHE_MAX = 5000; +const THUMB_CACHE_MAX = 800; const thumbCache = new Map(); export function cacheThumb(id: string, blob: Blob): void { @@ -729,7 +729,13 @@ export async function scanFolder( // frames it does not re-read are skipped on their size and their time. const bounded = from !== ''; const saved = bounded ? null : await loadWalk(folder.name); - const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) }; + const treeWalked = new Set(saved?.walked ?? []); + if (from) { + for (const p of [...treeWalked]) { + if (p === from || p.startsWith(from)) treeWalked.delete(p); + } + } + const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked }; // A queue comes back as paths, so the folders are asked for again; a reading // with nothing written down starts at the folder it was pointed at — the // picked one, or the one the reader right-clicked inside it. @@ -738,6 +744,10 @@ export async function scanFolder( const dir = await openAt(folder.handle, rel); if (dir) tree.pending.push({ dir, rel }); } + if (from && !tree.pending.some((p) => p.rel === from)) { + const dir = await openAt(folder.handle, from); + if (dir) tree.pending.unshift({ dir, rel: from }); + } } else { const dir = from ? await openAt(folder.handle, from) : folder.handle; if (dir) tree.pending.push({ dir, rel: from }); @@ -991,9 +1001,8 @@ export async function scanFolder( progress.done++; count(rel); } - // A RAW takes the one open there is before its lane does anything, so a - // folder of them is read a frame at a time whatever the lanes say. - const read = isRawName(handle.name) ? oneRawAtATime(() => readOne(handle, rel)) : readOne(handle, rel); + // Queue image decoding one at a time so memory usage stays low and UI stays smooth + const read = oneRawAtATime(() => readOne(handle, rel)); const lane = read .catch(() => undefined) .finally(() => lanes.delete(lane)); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index e893aa5..8eaa620 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -2042,3 +2042,10 @@ button.hint:disabled { cursor: default; } .adm:not(.lib-root) .adm-table td { padding: 9px 10px; } .adm:not(.lib-root) .adm-table .btn { min-height: 40px; } } + +.lib-folder-highlight { + background: var(--accent-soft, rgba(245, 158, 11, 0.25)) !important; + outline: 2px solid var(--accent, #f59e0b) !important; + border-radius: var(--radius-sm, 4px); + transition: background 0.3s ease, outline 0.3s ease; +}