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:
2026-09-28 18:31:06 +07:00
parent 12936731b3
commit 19fd5b644f
5 changed files with 195 additions and 66 deletions
+137 -66
View File
@@ -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>
);
}
+1
View File
@@ -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',
+1
View File
@@ -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',
+35
View File
@@ -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