From 19fd5b644f0f68b68476c8ecd970bf5e7ce249c8 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 18:31:06 +0700 Subject: [PATCH] web: trim the library to one toolbar and a folder menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the hint, ADD FOLDER and the two view switches onto a single row, with the switches drawn as icons, and move a folder's rescan and remove onto a right click — they belong to the folder, so they are asked for rather than always on screen. --- docker/frontend/scripts/library-check.mjs | 21 +++ docker/frontend/src/Library.tsx | 203 +++++++++++++++------- docker/frontend/src/i18n/en.ts | 1 + docker/frontend/src/i18n/vi.ts | 1 + docker/frontend/src/styles/app.css | 35 ++++ 5 files changed, 195 insertions(+), 66 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 6ee9fcc..aa56787 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -214,6 +214,27 @@ step('the grid view draws a card per frame', cards.length === 1, JSON.stringify( await page.click('[data-key="lib-view-preview"]'); await page.waitForSelector(thumbs, { timeout: 30_000 }); +// The toolbar carries the screen's one button and the two view icons on a single +// row; a folder's own rescan and remove exist only once it has been right-clicked. +const bar = await page.$eval('.lib-bar', (el) => ({ + rows: new Set( + [...el.children].map((c) => { + const box = c.getBoundingClientRect(); + return Math.round(box.top + box.height / 2); + }) + ).size, + icons: [...el.querySelectorAll('[data-key^="lib-view-"]')].every((b) => b.querySelector('svg')), +})); +step('one toolbar row holding the two view icons', bar.rows === 1 && bar.icons, JSON.stringify(bar)); +step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-rescan-"], [data-key^="lib-drop-"]')).length === 0); + +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)); +await page.keyboard.press('Escape'); +step('escape closes the menu', (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 acccee5..17b4b66 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -18,10 +18,12 @@ import { // 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=`). +// the open folder's strip of thumbnails across the bottom. The toolbar holds the +// one button that adds a folder and the two icons that swap the middle for every +// thumbnail of the open folder at once; a folder's own rescan and remove live on +// a right click. 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: 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 @@ -48,6 +50,8 @@ 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); const stopRef = useRef(false); const reload = useCallback(async () => { @@ -79,6 +83,27 @@ export function Library() { }; }, [reload]); + // 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 + // folder, which is half of how this screen is used. + useEffect(() => { + if (!menu) return; + const away = (e: Event) => { + if (e.target instanceof Element && e.target.closest('.lib-menu')) return; + setMenu(null); + }; + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenu(null); + }; + document.addEventListener('pointerdown', away); + document.addEventListener('keydown', key); + return () => { + document.removeEventListener('pointerdown', away); + document.removeEventListener('keydown', key); + }; + }, [menu]); + const scan = useCallback( async (folder: LibraryFolder) => { stopRef.current = false; @@ -154,7 +179,6 @@ export function Library() { }, [t]); 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. @@ -238,8 +262,11 @@ export function Library() { {canBrowseFolders() ? (
-

{t('lib.hint')}

-
+ {/* One toolbar for the whole screen: add, the scan in flight, and the + two ways to look at the open folder. The hint rides along on the + same row and ellipsises — the catalogue is the content, this bar + is furniture. */} +
@@ -248,9 +275,48 @@ export function Library() { {t('lib.stop')} ) : null} + + {t('lib.hint')} + {progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''} + + {folders.length > 0 ? ( + <> + + + + ) : null}
{folders.length === 0 ?

{t('lib.noFolders')}

: null} @@ -260,67 +326,8 @@ export function Library() {

{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. */} - -

- {/* 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) ? ( - <> - - - - ) : ( - - )} -
- ) : null} -
); } diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 15a2333..99f6080 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -234,6 +234,7 @@ export const en: Dict = { 'lib.remove': 'REMOVE', 'lib.reconnect': 'GRANT ACCESS AGAIN', 'lib.tree': 'FOLDERS', + 'lib.nodeHint': 'Right-click (or Shift+F10) to rescan or remove the folder.', '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 10f2246..01d3248 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -247,6 +247,7 @@ export const vi = { 'lib.remove': 'BỎ', 'lib.reconnect': 'CẤP LẠI QUYỀN', 'lib.tree': 'THƯ MỤC', + 'lib.nodeHint': 'Chuột phải (hoặc Shift+F10) để quét lại hay bỏ thư mục.', '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 6471ed1..4a4ca38 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1020,6 +1020,41 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } /* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on the right edge is what reaches the frames below. */ .lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; } +/* One toolbar, not two rows of chrome: ADD FOLDER, the hint, and the pair of view + icons. The hint gives up its width first and ellipsises — the whole sentence is + still there under the pointer. */ +.lib-bar { flex: 0 0 auto; align-items: center; margin-bottom: 10px; } +.lib-bar .btn { padding: 5px 10px; font-size: 12.5px; } +.lib-bar-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The views are a glyph each (a frame, a wall of thumbs), so the row stays a row + when the window is narrow. */ +.lib-icon { padding: 5px 8px; justify-content: center; } +/* A folder's own menu, on a right click. Fixed, so the tree's own scroll cannot + clip it; the position is clamped to the window when it is drawn. */ +.lib-menu { + position: fixed; + z-index: 40; + display: flex; + flex-direction: column; + min-width: 170px; + padding: 4px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.lib-menu-item { + border: 0; + border-radius: var(--radius-sm); + background: none; + padding: 7px 10px; + font-size: 12px; + letter-spacing: 0.04em; + text-align: left; + cursor: pointer; +} +.lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); } +.lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; } /* Settings drawer: the tabs that organise the page, plus the three panels. */ /* The management frame: a bordered box whose left column is the menu and whose