From d1abda141ab7e43f64d98a9a2d2106dd8ce9d7bd Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 18:15:14 +0700 Subject: [PATCH] web: browse the catalogue as a tree of folders The library page was a flat wall of thumbnails. It now reads like the admin pictures tab: a folder tree on the left, the picked node's frame on the stage, that node's frames in the strip below, and a chip pair to swap the stage for a wall of every thumbnail in the node. Folders are walked recursively (dot/@-prefixed names skipped, an unreadable subfolder is dropped rather than failing the scan), so a roll with a dated subfolder keeps both levels. Node counts include the subfolders underneath. --- docker/frontend/scripts/library-check.mjs | 72 +++-- docker/frontend/src/Library.tsx | 321 ++++++++++++++++------ docker/frontend/src/engine/library.ts | 45 ++- docker/frontend/src/i18n/en.ts | 8 +- docker/frontend/src/i18n/vi.ts | 8 +- 5 files changed, 339 insertions(+), 115 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 66a05c7..6ee9fcc 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -90,7 +90,7 @@ const context = await browser.newContext(); // The folder the picker would have returned, plus the revival of its handles on // the way back out of IndexedDB. await context.addInitScript( - ({ origin, names, folder }) => { + ({ origin, names, subs, folder }) => { const bytes = new Map(); const load = async (name) => { if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); @@ -103,20 +103,29 @@ await context.addInitScript( }); return handle; }; - const dirHandle = () => { - const handle = { kind: 'directory', name: folder }; + // The picked folder holds `names` at its top level and the subfolders in + // `subs` — a roll really is a folder of dated directories, which is what the + // tree column walks. + const dirHandle = (label, files, dirs) => { + const handle = { kind: 'directory', name: label }; Object.defineProperties(handle, { - values: { value: () => names.map(fileHandle)[Symbol.iterator]() }, + values: { + value: () => + [...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][ + Symbol.iterator + ](), + }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, }); return handle; }; - window.showDirectoryPicker = async () => dirHandle(); + const root = () => dirHandle(folder, names, subs); + window.showDirectoryPicker = async () => root(); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { - row.handle = row.handle.kind === 'directory' ? dirHandle() : fileHandle(row.handle.name); + row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); } return row; }; @@ -136,7 +145,7 @@ await context.addInitScript( }; } }, - { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG, RAW], folder: 'CheckRoll' } + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' } ); const page = await context.newPage(); @@ -169,10 +178,10 @@ const catalogue = () => // --- the run ---------------------------------------------------------------- await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); -const tiles = '[data-key^="lib-photo-"]'; -await page.waitForSelector(tiles, { timeout: 120_000 }); -await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 }); -step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`); +const thumbs = '[data-key^="lib-thumb-"]'; +await page.waitForSelector(thumbs, { timeout: 120_000 }); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 }); +step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${RAW}`); const indexed = await catalogue(); for (const name of [JPG, RAW]) { @@ -180,11 +189,37 @@ for (const name of [JPG, RAW]) { step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`); } -// One tap on a tile hands the frame to the studio. The RAW is the harder half — -// it has to be read back off the (stand-in) handle and developed — so it is the -// one that gets opened. +// The tree: the picked folder, with the subfolder a frame sits in under it, each +// carrying the frames at or below it. The RAW is the nested one. +const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) => + els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent]) +); +step( + 'tree lists the folder and its subfolder', + nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026', + JSON.stringify(nodes) +); + +// Opening the subfolder redraws the strip and the frame above it. +await page.click('[data-key="lib-node-CheckRoll/2026"]'); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 30_000 }); +const staged = await page.locator('[data-key="lib-stage-meta"]').innerText(); +step('the open subfolder draws its own strip and frame', /P1010256\.RW2/.test(staged), staged.replace(/\n/g, ' · ')); + +// The switch: the middle becomes every thumbnail of the open node at once. +await page.click('[data-key="lib-view-grid"]'); +await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 }); +const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key)); +step('the grid view draws a card per frame', cards.length === 1, JSON.stringify(cards)); +await page.click('[data-key="lib-view-preview"]'); +await page.waitForSelector(thumbs, { timeout: 30_000 }); + +// One tap on a thumbnail raises the frame, and the button under it hands it to +// the studio. The RAW is the harder half — it has to be read back off the +// (stand-in) handle and developed — so it is the one that gets opened. const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id; -await page.click(`[data-key="lib-open-${rawId}"]`); +await page.click(`[data-key="lib-thumb-${rawId}"]`); +await page.click('[data-key="lib-open"]'); await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 }); await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 }); step('studio developed the frame from its handle', true, 'stage no longer on the drop zone'); @@ -215,9 +250,10 @@ for (let i = 0; i < 40 && !filed.length; i++) { step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed)); await page.goto(`${base}/library`); -await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`); -const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText(); -step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · ')); +await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`); +await page.click(`[data-key="lib-thumb-${rawId}"]`); +const meta = await page.locator('[data-key="lib-stage-meta"]').innerText(); +step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · ')); await browser.close(); sampleServer.close(); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 06e602d..42cded5 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -16,21 +16,35 @@ import { type ScanProgress, } from './engine/library'; -// The catalogue screen: folders from the visitor's own disk, a tile per frame, -// and a recipe remembered per frame. Nothing is uploaded — the page never holds -// more than the thumbnails, and the RAW itself is read only when a frame is -// opened in the studio (`/app?lib=`). +// The catalogue screen, laid out like the admin's picture manager: the tree of +// folders and subfolders down the left, the frame that is up in the middle, and +// the open folder's strip of thumbnails across the bottom. The switch beside the +// tree title swaps the middle for every thumbnail of the open folder at once. +// Nothing is uploaded — the page never holds more than thumbnails, and the RAW +// itself is read only when a frame is opened in the studio (`/app?lib=`). // -// ponytail: no rating, no flags, no collections, no search. IndexedDB already -// holds the fields a few of those would need (folder, taken); add them once the -// catalogue is big enough that scrolling it stops working. +// ponytail: the tree does not collapse, and there is no search, rating or flag. +// A roll of date folders is three levels deep at most; add a collapsing node or +// a filter once the column stops being scrollable in practice. +interface Node { + // The tree's own key: a folder name, or `folder/sub/dir`. + key: string; + label: string; + depth: number; + count: number; +} + export function Library() { const { t } = useI18n(); const [folders, setFolders] = useState([]); const [ready, setReady] = useState>(new Set()); const [photos, setPhotos] = useState([]); const [edited, setEdited] = useState>(new Set()); - const [pick, setPick] = useState(null); + // Which tree node is open, which frame is up, and which of the two middle + // views (the frame, or every thumbnail of the open node) is on. + const [node, setNode] = useState(null); + const [up, setUp] = useState(null); + const [view, setView] = useState<'preview' | 'grid'>('preview'); const [progress, setProgress] = useState(null); const [note, setNote] = useState(null); const [busy, setBusy] = useState(false); @@ -51,6 +65,7 @@ export function Library() { const rows = await listFolders(); if (!alive) return; setFolders(rows); + setNode((prev) => prev ?? rows[0]?.name ?? null); const ok = new Set(); for (const folder of rows) { if (await ensurePermission(folder.handle)) ok.add(folder.name); @@ -90,7 +105,7 @@ export function Library() { if (!folder) return; setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name))); setReady((prev) => new Set(prev).add(folder.name)); - setPick(folder.name); + setNode(folder.name); await scan(folder); } catch (err) { // Closing the picker is a "not now", not a failure worth a line of text. @@ -114,11 +129,12 @@ export function Library() { const drop = useCallback( async (folder: LibraryFolder) => { await removeFolder(folder.name); - setFolders((prev) => prev.filter((f) => f.name !== folder.name)); - if (pick === folder.name) setPick(null); + const rest = folders.filter((f) => f.name !== folder.name); + setFolders(rest); + setNode(rest[0]?.name ?? null); await reload(); }, - [pick, reload] + [folders, reload] ); // The frame goes to the studio through the address bar: the studio owns the @@ -137,9 +153,69 @@ export function Library() { window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`; }, [t]); - const shown = useMemo(() => (pick ? photos.filter((p) => p.folder === pick) : photos), [photos, pick]); + const rootName = node ? node.split('/')[0] : null; + const selected = folders.find((f) => f.name === rootName) ?? null; + + // The tree: one row per picked folder, then one per subfolder a frame sits in, + // indented by its depth, each carrying how many frames it holds at or under it. + const nodes = useMemo(() => { + const hits = new Map(); + const dirs = new Map>(); + for (const photo of photos) { + hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1); + if (!photo.dir) continue; + const set = dirs.get(photo.folder) ?? new Set(); + dirs.set(photo.folder, set); + let path = ''; + for (const part of photo.dir.split('/')) { + path = path ? `${path}/${part}` : part; + set.add(path); + hits.set(`${photo.folder}/${path}`, (hits.get(`${photo.folder}/${path}`) ?? 0) + 1); + } + } + const out: Node[] = []; + for (const folder of folders) { + out.push({ key: folder.name, label: folder.name, depth: 0, count: hits.get(folder.name) ?? 0 }); + // Path order puts a folder before its own children, which is the order the + // indented rows have to come in. + const own = [...(dirs.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b)); + for (const dir of own) { + const parts = dir.split('/'); + out.push({ + key: `${folder.name}/${dir}`, + label: parts[parts.length - 1], + depth: parts.length, + count: hits.get(`${folder.name}/${dir}`) ?? 0, + }); + } + } + return out; + }, [folders, photos]); + + // What the open node draws: its own frames, and the ones in every subfolder + // under it — a roll's top level usually holds no file of its own. + const shown = useMemo(() => { + if (!rootName) return photos; + const dir = node === rootName ? '' : (node ?? '').slice(rootName.length + 1); + return photos.filter((p) => p.folder === rootName && (dir === '' || p.dir === dir || p.dir.startsWith(`${dir}/`))); + }, [node, photos, rootName]); + + const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null; const urls = useThumbUrls(shown); + // The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose + // preview the camera never wrote. A tile only carries a handler where a click + // on the picture means "open it" — in the strip the click belongs to the frame + // it raises, not to the studio. + const tile = (photo: LibraryPhoto, onPick?: () => void) => + urls[photo.id] ? ( + + ) : ( +
+ RAW +
+ ); + return (
@@ -179,82 +255,167 @@ export function Library() { {folders.length === 0 ?

{t('lib.noFolders')}

: null} -
- {folders.length > 1 ? ( - - ) : null} - {folders.map((folder) => ( - + {folders.length > 0 ? ( +
+

+ {t('lib.tree')} + {shown.length} + + {/* One folder, two ways to look at it: the frame on its own, + or the whole node as a wall of thumbnails. */} - {ready.has(folder.name) ? ( - <> + +

+ + {/* What can be done to the folder the open node belongs to: a + scan again, forget it, or hand back the permission it lost. */} + {selected ? ( +
+ {ready.has(selected.name) ? ( + <> + + + + ) : ( - - - ) : ( - - )} - - ))} -
- - {shown.length === 0 && !busy ?

{t('lib.empty')}

: null} - -
- {shown.map((photo) => ( -
- {urls[photo.id] ? ( - void open(photo)} title={t('lib.open')} /> - ) : ( -
void open(photo)} title={t('lib.open')}> - RAW -
- )} -
- {photo.name} - - {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} - {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} - + )}
- -
- ))} -
+ ) : null} + +
+ + +
+ {view === 'grid' ? ( +
+ {shown.map((photo) => ( +
+ {tile(photo, () => void open(photo))} +
+ {photo.name} + + {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} + {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} + +
+ +
+ ))} +
+ ) : frame ? ( + <> + {urls[frame.id] ? ( + void open(frame)} + title={t('lib.open')} + /> + ) : ( +
void open(frame)} title={t('lib.open')}> + RAW +
+ )} +
+ {frame.name} + {frame.dir || node} + + {new Date(frame.taken).toLocaleString()} · {t('lib.size', { mb: (frame.size / 1048576).toFixed(1) })} + {edited.has(frame.id) ? ` · ${t('lib.edited')}` : ''} + +
+
+ +
+ + ) : ( +

{t('lib.empty')}

+ )} +
+
+ + {/* The strip: one thumbnail per frame the open node holds, in the + order the catalogue reads them (newest shutter first). */} + {view === 'preview' ? ( +
+ {shown.map((photo) => ( + + ))} + {shown.length === 0 ?

{t('lib.empty')}

: null} +
+ ) : null} +
+ ) : null}
) : ( @@ -266,7 +427,7 @@ export function Library() { // One object URL per tile, made when the row does and dropped with it: a folder // of thousands cannot hold thousands of live URLs, and `shown` is the only list -// the grid paints. +// the panels paint. function useThumbUrls(photos: LibraryPhoto[]): Record { const [urls, setUrls] = useState>({}); useEffect(() => { diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index a5d2b4f..fe25d69 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -35,10 +35,13 @@ export interface LibraryFolder { } export interface LibraryPhoto { - // `${folder}/${name}` — one frame appears once in the catalogue, under the - // folder it was found in. + // `${folder}/${dir}/${name}` — one frame appears once in the catalogue, under + // the folder it was found in. id: string; folder: string; + // The subfolder it sits in, relative to the picked folder — '' at the root. + // This is what the tree column reads. + dir: string; name: string; handle: FileSystemFileHandle; thumb: Blob | null; @@ -221,6 +224,29 @@ export interface ScanProgress { added: number; } +// The walk goes all the way down: a picked folder is usually a roll of +// date-named subfolders, and a catalogue that only saw its top level would be +// empty. Names beginning with `.` or `@` are skipped — that is `.thumbnails`, +// `.git`, and the `@eaDir` a Synology writes beside every frame. +async function collectFiles( + dir: FileSystemDirectoryHandle, + prefix: string, + out: { handle: FileSystemFileHandle; rel: string }[] +): Promise { + for await (const entry of dir.values()) { + if (/^[.@]/.test(entry.name)) continue; + if (entry.kind === 'directory') { + try { + await collectFiles(entry as FileSystemDirectoryHandle, `${prefix}${entry.name}/`, out); + } catch { + // A subfolder that will not open is skipped; the rest of the roll stands. + } + } else if (isSupportedPhoto(entry.name)) { + out.push({ handle: entry as FileSystemFileHandle, rel: `${prefix}${entry.name}` }); + } + } +} + // Walk the folder, keep what is new or changed, and leave the rest alone: a // second scan of a 2000-file folder only reads the frames that moved. // @@ -234,13 +260,8 @@ export async function scanFolder( shouldStop?: () => boolean ): Promise { const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p])); - const entries: FileSystemFileHandle[] = []; - for await (const entry of folder.handle.values()) { - // `values()` is typed as the base handle, and only a file handle has the - // getFile this walk needs. - const file = entry as FileSystemFileHandle; - if (entry.kind === 'file' && isSupportedPhoto(file.name)) entries.push(file); - } + const entries: { handle: FileSystemFileHandle; rel: string }[] = []; + await collectFiles(folder.handle, '', entries); const progress: ScanProgress = { folder: folder.name, total: entries.length, done: 0, added: 0 }; let batch: LibraryPhoto[] = []; // One transaction per batch, a put per row: a store with `keyPath: 'id'` takes @@ -258,12 +279,12 @@ export async function scanFolder( tx.onerror = () => reject(tx.error); }); }; - for (const handle of entries) { + for (const { handle, rel } of entries) { if (shouldStop?.()) break; progress.done++; try { const file = await handle.getFile(); - const id = photoId(folder.name, handle.name); + const id = photoId(folder.name, rel); const seen = known.get(id); if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) { onProgress?.(progress); @@ -271,9 +292,11 @@ export async function scanFolder( } const bytes = new Uint8Array(await file.arrayBuffer()); const [thumb, taken] = [await makeThumbnail(file, bytes), (await readCapturedAt(bytes)) ?? file.lastModified]; + const cut = rel.lastIndexOf('/'); batch.push({ id, folder: folder.name, + dir: cut < 0 ? '' : rel.slice(0, cut), name: handle.name, handle, thumb, diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index ca51a0a..15a2333 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -225,7 +225,7 @@ export const en: Dict = { 'photos.failed': 'That did not work: {msg}', 'photos.noLabels': 'No strip labels yet', - 'lib.title': 'Library', + 'lib.title': 'LIBRARY', 'lib.hint': 'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.', 'lib.add': 'ADD FOLDER', @@ -233,7 +233,9 @@ export const en: Dict = { 'lib.rescan': 'RESCAN', 'lib.remove': 'REMOVE', 'lib.reconnect': 'GRANT ACCESS AGAIN', - 'lib.all': 'ALL', + 'lib.tree': 'FOLDERS', + 'lib.viewPreview': 'SINGLE', + 'lib.viewGrid': 'ALL THUMBS', 'lib.open': 'Open in the studio', 'lib.edited': 'edited', 'lib.size': '{mb} MB', @@ -250,7 +252,7 @@ export const en: Dict = { 'nav.admin': 'Admin', 'nav.photos': 'My photos', 'nav.profile': 'Profile', - 'nav.library': 'Library', + 'nav.library': 'LIBRARY', 'nav.studio': 'Studio', 'adm.title': 'Strip moderation', 'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 907747f..10f2246 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -17,7 +17,7 @@ export const vi = { 'nav.admin': 'Quản trị', 'nav.photos': 'Ảnh của tôi', 'nav.profile': 'Hồ sơ', - 'nav.library': 'Thư viện', + 'nav.library': 'THƯ VIỆN', 'nav.studio': 'Studio', 'nav.guest': 'Khách', @@ -238,7 +238,7 @@ export const vi = { 'photos.failed': 'Không thực hiện được: {msg}', 'photos.noLabels': 'Chưa có nhãn dải phim', - 'lib.title': 'Thư viện', + 'lib.title': 'THƯ VIỆN', 'lib.hint': 'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.', 'lib.add': 'THÊM THƯ MỤC', @@ -246,7 +246,9 @@ export const vi = { 'lib.rescan': 'QUÉT LẠI', 'lib.remove': 'BỎ', 'lib.reconnect': 'CẤP LẠI QUYỀN', - 'lib.all': 'TẤT CẢ', + 'lib.tree': 'THƯ MỤC', + 'lib.viewPreview': 'MỘT ẢNH', + 'lib.viewGrid': 'TOÀN BỘ', 'lib.open': 'Mở trong studio', 'lib.edited': 'đã chỉnh', 'lib.size': '{mb} MB',