diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index b237bb3..f9ecb68 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -51,6 +51,9 @@ const sampleServer = createServer(async (req, res) => { return; } const bytes = await readFile(path.join(SAMPLES, name)); + // A beat per frame: the catalogue reads two files faster than a poll, and the + // ring that says "this roll is being read" needs a window to be caught in. + await new Promise((resolve) => setTimeout(resolve, 250)); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.length, @@ -104,23 +107,24 @@ await context.addInitScript( return handle; }; // 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) => { + // `subs`, each `{ files, dirs }` — a roll really is a folder of dated + // directories inside dated directories, which is what the tree column walks. + const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label }; Object.defineProperties(handle, { values: { value: () => - [...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][ - Symbol.iterator - ](), + [ + ...(node.files ?? []).map(fileHandle), + ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), + ][Symbol.iterator](), }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, }); return handle; }; - const root = () => dirHandle(folder, names, subs); + const root = () => dirHandle(folder, { files: names, dirs: subs }); window.showDirectoryPicker = async () => root(); const revive = (row) => { @@ -145,7 +149,14 @@ await context.addInitScript( }; } }, - { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' } + { + origin: `http://127.0.0.1:${SAMPLE_PORT}`, + names: [JPG], + // One frame at the top, one a folder down, one two folders down: the walk has + // to reach the bottom of the roll, not just its first level. + subs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } } }, + folder: 'CheckRoll', + } ); const page = await context.newPage(); @@ -178,10 +189,23 @@ const catalogue = () => // --- the run ---------------------------------------------------------------- await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); +// A folder that is being read says so on its own row, not only in the toolbar. +const ringed = await page + .waitForFunction( + () => { + const row = document.querySelector('.adm-album[aria-busy="true"]'); + return row?.querySelector('.lib-spin') ? row.dataset.key : null; + }, + null, + { timeout: 10_000, polling: 50 } + ) + .then((handle) => handle.jsonValue()) + .catch(() => null); +step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed)); 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}`); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 }); +step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${RAW}`); const indexed = await catalogue(); for (const name of [JPG, RAW]) { @@ -195,22 +219,27 @@ 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', + 'the tree draws the roll as folders under folders', + JSON.stringify(nodes) === + JSON.stringify([ + ['lib-node-CheckRoll', '3'], + ['lib-node-CheckRoll/2026', '2'], + ['lib-node-CheckRoll/2026/04', '1'], + ]), 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 }); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, 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, ' · ')); +step('the open subfolder draws its own strip and frame', /P1010256\.(RW2|JPG)/.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)); +step('the grid view draws a card per frame', cards.length === 2, JSON.stringify(cards)); await page.click('[data-key="lib-view-preview"]'); await page.waitForSelector(thumbs, { timeout: 30_000 }); @@ -239,7 +268,7 @@ step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null) const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); step( 'no FOLDERS heading, the count on the folder row', - (await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '2', + (await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '3', `${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px` ); step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`); @@ -258,6 +287,11 @@ step( (await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null, await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent) ); +step( + 'the column is titled with the folder the tree belongs to', + (await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A', + await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent) +); // The empty part of the column is where one more folder comes from. await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } }); @@ -267,6 +301,23 @@ step('a right click on the empty column offers a folder', blank.join(',') === 'l await page.keyboard.press('Escape'); step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); +// The divider drags the column, and what the visit leaves behind — the width the +// column was left at and the folder that was open — is what the next visit opens +// with. The right click on the empty column answered with a folder, not with a +// folder opened, so the open node is still the subfolder chosen above. +const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox(); +await page.mouse.move(gripBox.x + 4, gripBox.y + 120); +await page.mouse.down(); +await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 }); +await page.mouse.up(); +const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); +await page.reload(); +await page.waitForSelector(thumbs, { timeout: 120_000 }); +const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); +const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key); +step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`); +step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where); + // 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. diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 6847428..a7bc595 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useI18n } from './i18n/I18nProvider'; import { canBrowseFolders, @@ -37,6 +37,32 @@ interface Node { count: number; } +// The two things this screen remembers between visits: the folder that was open, +// and how much room its column was given. +const NODE_KEY = 'recipescam.library.node'; +const COLUMN_KEY = 'recipescam.library.column'; +const COLUMN_MIN = 88; +const COLUMN_MAX = 460; +const COLUMN_DEFAULT = 118; + +// Private mode throws on localStorage rather than returning null: a screen that +// cannot remember is fine, a screen that cannot load is not. +function remembered(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function keep(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // The choice just does not outlive the visit. + } +} + export function Library() { const { t } = useI18n(); const [folders, setFolders] = useState([]); @@ -44,10 +70,15 @@ export function Library() { const [photos, setPhotos] = useState([]); const [edited, setEdited] = useState>(new Set()); // 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); + // views (the frame, or every thumbnail of the open node) is on. The open node + // is picked up where the last visit left it. + const [node, setNode] = useState(() => remembered(NODE_KEY)); const [up, setUp] = useState(null); const [view, setView] = useState<'preview' | 'grid'>('preview'); + const [colWidth, setColWidth] = useState(() => { + const saved = Number(remembered(COLUMN_KEY)); + return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT; + }); const [progress, setProgress] = useState(null); const [note, setNote] = useState(null); const [busy, setBusy] = useState(false); @@ -56,6 +87,13 @@ export function Library() { // one more folder. const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null); const stopRef = useRef(false); + // Where the drag of the column divider started, and how wide the column was + // then — the pointer only ever reports a position, not a distance moved. + const grip = useRef<{ x: number; w: number } | null>(null); + + // The catalogue is loaded once at start-up; until it is, the tree only knows + // the folders and cannot tell whether the remembered subfolder still exists. + const [loaded, setLoaded] = useState(false); const reload = useCallback(async () => { const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); @@ -72,7 +110,9 @@ export function Library() { const rows = await listFolders(); if (!alive) return; setFolders(rows); - setNode((prev) => prev ?? rows[0]?.name ?? null); + // The remembered folder only stands while the folder it belongs to does: a + // folder that was removed is not a folder to open on. + setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null))); const ok = new Set(); for (const folder of rows) { if (await ensurePermission(folder.handle)) ok.add(folder.name); @@ -80,12 +120,23 @@ export function Library() { if (!alive) return; setReady(ok); await reload(); + if (!alive) return; + setLoaded(true); })(); return () => { alive = false; }; }, [reload]); + // What the screen remembers: the folder that was open, and the width its column + // was dragged to. + useEffect(() => { + if (node) keep(NODE_KEY, node); + }, [node]); + useEffect(() => { + keep(COLUMN_KEY, String(colWidth)); + }, [colWidth]); + // The menu closes on the next click anywhere but its own items, and on Escape. // Escape is the whole reason this listens on the document instead of sitting // behind a full-screen scrim: a scrim would eat the click that opens the next @@ -234,6 +285,17 @@ export function Library() { return out; }, [folders, photos]); + // The folder the open node belongs to: its name titles the column, and its + // handle is what the menu acts on. + const root = folders.find((f) => f.name === rootName) ?? null; + + // A remembered subfolder that no longer holds a frame falls back to the folder + // it sits in, so the screen never opens on a node that is not drawn. + useEffect(() => { + if (!loaded || !node || nodes.some((n) => n.key === node)) return; + setNode(nodes.find((n) => n.key === node.split('/')[0])?.key ?? nodes[0]?.key ?? null); + }, [loaded, node, nodes]); + // 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(() => { @@ -342,7 +404,7 @@ export function Library() { {folders.length > 0 ? (
-
+
{/* The shelf itself answers a right click on its empty part: the column is the folder control, so one more folder is added where folders are. */} @@ -356,38 +418,84 @@ export function Library() { setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) }); }} > - {nodes.map((n) => ( - - ))} + {/* The column is titled with the folder the tree belongs to, so + a renamed roll is named where its frames are named. */} + + {root?.label || root?.name} + + {nodes.map((n) => { + const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null; + // A roll being read says so on its own row: while a scan runs + // the toolbar's progress line is nowhere near the eye. + const scanning = !!folder && progress?.folder === folder.name; + return ( + + ); + })} +
{ + e.currentTarget.setPointerCapture(e.pointerId); + grip.current = { x: e.clientX, w: colWidth }; + }} + onPointerMove={(e) => { + const held = grip.current; + if (!held) return; + // 88px is the narrowest a name and its count still read at, + // 460 the widest the stage can lose and stay a stage. + setColWidth(Math.round(Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, held.w + e.clientX - held.x)))); + }} + onPointerUp={(e) => { + grip.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }} + onKeyDown={(e) => { + const step = e.key === 'ArrowLeft' ? -12 : e.key === 'ArrowRight' ? 12 : 0; + if (!step) return; + e.preventDefault(); + setColWidth((w) => Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, w + step))); + }} + /> +
{view === 'grid' ? (
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index f7c24c0..af0828a 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -237,6 +237,7 @@ export const en: Dict = { 'lib.reconnect': 'GRANT ACCESS AGAIN', 'lib.nodeHint': 'Right-click to rename, rescan or remove the folder.', 'lib.colHint': 'Right-click the empty part of this column to add a folder.', + 'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)', 'lib.viewPreview': 'SINGLE', 'lib.viewGrid': 'ALL THUMBS', 'lib.open': 'Open in the studio', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 1a68a4d..cfac199 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -250,6 +250,7 @@ export const vi = { 'lib.reconnect': 'CẤP LẠI QUYỀN', 'lib.nodeHint': 'Chuột phải để đổi tên, quét lại hay bỏ thư mục.', 'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.', + 'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)', 'lib.viewPreview': 'MỘT ẢNH', 'lib.viewGrid': 'TOÀN BỘ', 'lib.open': 'Mở trong studio', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 9f58e98..de523fd 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1007,15 +1007,47 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } is left, the strip keeps its natural height on the bottom. */ .lib-root .adm-col { flex: 1 1 auto; min-height: 0; } /* The column of folders is a list of names and a count — the stage is the point - of the screen, so the column takes the least it can and gives the rest away. */ + of the screen, so the column takes the least it can and gives the rest away. + Its width is a variable the divider writes, and the divider itself is the middle + track, thin enough to read as the seam between the two panels. */ .lib-root .adm-col > .adm-split { flex: 1 1 auto; min-height: 0; align-items: stretch; - grid-template-columns: minmax(88px, 118px) minmax(0, 1fr); - gap: 8px; + grid-template-columns: var(--lib-col, 118px) 9px minmax(0, 1fr); + gap: 0; +} +.lib-grip { cursor: col-resize; border-radius: 999px; touch-action: none; } +.lib-grip:hover, .lib-grip:focus-visible { background: var(--accent-soft); outline: none; } +/* The folder the tree belongs to, named over it: a roll's own name is the one + thing a renamed folder has to say here. */ +.lib-col-title { + margin: 0 0 6px; + padding: 0 2px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lib-root .adm-album { padding: 6px 8px; gap: 6px; } +/* A roll being read turns a ring on its own row. */ +.lib-spin { + flex: 0 0 auto; + width: 11px; + height: 11px; + border-radius: 999px; + border: 2px solid var(--border); + border-top-color: var(--accent); + animation: lib-spin 0.7s linear infinite; +} +@keyframes lib-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .lib-spin { animation-duration: 2.4s; } +} .lib-root .adm-col > .adm-filmstrip { flex: 0 0 auto; } .lib-root .adm-album-list { max-height: none; flex: 1 1 auto; min-height: 0; } .lib-root .adm-stage { min-height: 0; overflow: hidden; }