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.
This commit is contained in:
2026-09-28 18:15:14 +07:00
parent 7ba1e64e7c
commit d1abda141a
5 changed files with 339 additions and 115 deletions
+34 -11
View File
@@ -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<void> {
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<ScanProgress> {
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,