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>
);
}