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
+54 -18
View File
@@ -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();