web: trim the library to one toolbar and a folder menu
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
+137
-66
@@ -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=<id>`).
|
||||
// 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=<id>`).
|
||||
//
|
||||
// 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<ScanProgress | null>(null);
|
||||
const [note, setNote] = useState<string | null>(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() ? (
|
||||
<div className="adm-frame" data-key="lib-frame">
|
||||
<div className="adm-pane">
|
||||
<p className="hint adm-sub">{t('lib.hint')}</p>
|
||||
<div className="chip-row" style={{ marginBottom: 12 }}>
|
||||
{/* 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. */}
|
||||
<div className="chip-row lib-bar">
|
||||
<button type="button" className="btn primary" data-key="lib-add" disabled={busy} onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
@@ -248,9 +275,48 @@ export function Library() {
|
||||
{t('lib.stop')}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="hint lib-bar-hint" title={t('lib.hint')}>
|
||||
{t('lib.hint')}
|
||||
</span>
|
||||
<span className="hint" data-key="lib-progress">
|
||||
{progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''}
|
||||
</span>
|
||||
<span className="adm-spacer" />
|
||||
{folders.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip lib-icon${view === 'preview' ? ' on' : ''}`}
|
||||
data-key="lib-view-preview"
|
||||
aria-pressed={view === 'preview'}
|
||||
aria-label={t('lib.viewPreview')}
|
||||
title={t('lib.viewPreview')}
|
||||
onClick={() => setView('preview')}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor">
|
||||
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" />
|
||||
<circle cx="5.4" cy="6.8" r="1.1" fill="currentColor" stroke="none" />
|
||||
<path d="M2.4 11.6 6 8.5l2.3 1.9 2.5-2.6 2.8 2.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip lib-icon${view === 'grid' ? ' on' : ''}`}
|
||||
data-key="lib-view-grid"
|
||||
aria-pressed={view === 'grid'}
|
||||
aria-label={t('lib.viewGrid')}
|
||||
title={t('lib.viewGrid')}
|
||||
onClick={() => setView('grid')}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor">
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" />
|
||||
<rect x="9" y="2" width="5" height="5" rx="1" />
|
||||
<rect x="2" y="9" width="5" height="5" rx="1" />
|
||||
<rect x="9" y="9" width="5" height="5" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
|
||||
@@ -260,67 +326,8 @@ export function Library() {
|
||||
<h3 className="adm-row-title">
|
||||
{t('lib.tree')}
|
||||
<span className="adm-row-count">{shown.length}</span>
|
||||
<span className="adm-spacer" />
|
||||
{/* One folder, two ways to look at it: the frame on its own,
|
||||
or the whole node as a wall of thumbnails. */}
|
||||
<button
|
||||
type="button"
|
||||
className={`chip${view === 'preview' ? ' on' : ''}`}
|
||||
data-key="lib-view-preview"
|
||||
aria-pressed={view === 'preview'}
|
||||
onClick={() => setView('preview')}
|
||||
>
|
||||
{t('lib.viewPreview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip${view === 'grid' ? ' on' : ''}`}
|
||||
data-key="lib-view-grid"
|
||||
aria-pressed={view === 'grid'}
|
||||
onClick={() => setView('grid')}
|
||||
>
|
||||
{t('lib.viewGrid')}
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="chip-row">
|
||||
{ready.has(selected.name) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
data-key={`lib-rescan-${selected.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void scan(selected)}
|
||||
>
|
||||
{t('lib.rescan')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
data-key={`lib-drop-${selected.name}`}
|
||||
onClick={() => void drop(selected)}
|
||||
>
|
||||
{t('lib.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`lib-reconnect-${selected.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void reconnect(selected)}
|
||||
>
|
||||
{t('lib.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="adm-split">
|
||||
<aside className="adm-album-list" data-key="lib-tree">
|
||||
{nodes.map((n) => (
|
||||
@@ -330,8 +337,24 @@ export function Library() {
|
||||
className={`adm-album${node === n.key ? ' on' : ''}`}
|
||||
data-key={`lib-node-${n.key}`}
|
||||
aria-current={node === n.key}
|
||||
title={t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 14 }}
|
||||
onClick={() => setNode(n.key)}
|
||||
// Rescan and remove act on the folder that was picked, so a
|
||||
// right click anywhere on a roll brings up that root's menu.
|
||||
// Shift+F10 raises the same event, which is the keyboard's
|
||||
// way in.
|
||||
onContextMenu={(e) => {
|
||||
const folder = folders.find((f) => f.name === n.key.split('/')[0]);
|
||||
if (!folder) return;
|
||||
e.preventDefault();
|
||||
const box = e.currentTarget.getBoundingClientRect();
|
||||
setMenu({
|
||||
folder,
|
||||
x: Math.min(e.clientX || box.left, window.innerWidth - 190),
|
||||
y: Math.min(e.clientY || box.top, window.innerHeight - 96),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="adm-album-name">{n.label}</span>
|
||||
<span className="adm-album-count">{n.count}</span>
|
||||
@@ -421,6 +444,54 @@ export function Library() {
|
||||
) : (
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
)}
|
||||
|
||||
{/* The folder menu: the two things that belong to a folder rather than to a
|
||||
frame, kept off the page until they are asked for. A folder whose
|
||||
permission has lapsed gets the one that can be done about it. */}
|
||||
{menu ? (
|
||||
<div className="lib-menu" data-key="lib-menu" style={{ left: menu.x, top: menu.y }} onContextMenu={(e) => e.preventDefault()}>
|
||||
{ready.has(menu.folder.name) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-rescan-${menu.folder.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void scan(menu.folder);
|
||||
}}
|
||||
>
|
||||
{t('lib.rescan')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-drop-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void drop(menu.folder);
|
||||
}}
|
||||
>
|
||||
{t('lib.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-reconnect-${menu.folder.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void reconnect(menu.folder);
|
||||
}}
|
||||
>
|
||||
{t('lib.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user