diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index aa56787..b237bb3 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -231,10 +231,42 @@ step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-resc await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); -step('a right click on a folder opens its menu', menu.join(',') === 'lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); +step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); await page.keyboard.press('Escape'); step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null); +// The column is names and counts, nothing else: the count rides on the row. +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', + `${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`); + +// A rename paints a label over the folder: the row it belongs to, and the frames +// under it, do not move — the node key is still the directory's own name. +page.once('dialog', (dialog) => dialog.accept('Roll A')); +await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-rename-CheckRoll"]', { timeout: 10_000 }); +await page.click('[data-key="lib-rename-CheckRoll"]'); +await page.waitForFunction(() => document.querySelector('[data-key="lib-node-CheckRoll"] .adm-album-name')?.textContent === 'Roll A', null, { + timeout: 10_000, +}); +step( + 'a folder can be renamed without being moved', + (await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null, + await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-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 } }); +await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); +const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); +step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank)); +await page.keyboard.press('Escape'); +step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); + // 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 17b4b66..6847428 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -10,6 +10,7 @@ import { pickFolder, readPhotoFile, removeFolder, + renameFolder, scanFolder, type LibraryFolder, type LibraryPhoto, @@ -50,8 +51,10 @@ export function Library() { const [progress, setProgress] = useState(null); const [note, setNote] = useState(null); const [busy, setBusy] = useState(false); - // The folder menu: what a right click on a tree row opens, and where. - const [menu, setMenu] = useState<{ folder: LibraryFolder; x: number; y: number } | null>(null); + // The folder menu: what a right click opens, and where. No folder means the + // right click landed on the empty part of the column, which is the way in for + // one more folder. + const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null); const stopRef = useRef(false); const reload = useCallback(async () => { @@ -151,6 +154,21 @@ export function Library() { [scan, t] ); + // A rename only paints a label over the folder — the directory it points at is + // not touched, which is the whole point of keeping the two apart. + const rename = useCallback( + async (folder: LibraryFolder) => { + // The browser's own box: one line, no dialog to build, and it already knows + // how to ask. ponytail: swap it for an inline field if a label ever needs + // validating or previewing. + const label = window.prompt(t('lib.renameAsk'), folder.label ?? folder.name)?.trim(); + if (!label || label === (folder.label ?? folder.name)) return; + await renameFolder(folder, label); + setFolders((prev) => prev.map((f) => (f.name === folder.name ? { ...f, label } : f))); + }, + [t] + ); + const drop = useCallback( async (folder: LibraryFolder) => { await removeFolder(folder.name); @@ -199,7 +217,7 @@ export function Library() { } const out: Node[] = []; for (const folder of folders) { - out.push({ key: folder.name, label: folder.name, depth: 0, count: hits.get(folder.name) ?? 0 }); + out.push({ key: folder.name, label: folder.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)); @@ -323,13 +341,21 @@ export function Library() { {folders.length > 0 ? (
-

- {t('lib.tree')} - {shown.length} -

-