Files
RecipesCam/docker/frontend/src/Library.tsx
T
3dtours 5e28b42067 web: fold all keeps the top-level row open
COLLAPSE ALL folded the top-level row as well, and that row is already its own
fold: one click on its name shuts it and takes the whole subtree with it. Folding
it from the menu hid every row of the tree — the item repeated a click the reader
already had, while the one thing a click there cannot reach, the folders nested
below, was the part that got lost with it.

So the item folds the folders nested below the top level: every row with a
subfolder under it that is not itself a folder picked at the top level. The
top-level row stays drawn and stays open, its own subfolders are drawn folded,
and no top-level row ever leaves the column. `nested` is computed off `parents`
once, and the item is disabled on a folder with nothing nested under it.

Verified:
  library-check.mjs — 35 steps, all passed. The fold step now reads the rows the
    click leaves behind instead of a row count: the fully open roll (4 rows)
    becomes exactly CheckRoll aria-expanded=true, CheckRoll/2026
    aria-expanded=false, CheckRoll/Empty (a leaf, so no aria-expanded) —
    CheckRoll/2026/04 is away, the top-level row is not — with the strip's 2
    tiles unmoved and the row menu still leading with lib-menu-collapse.
  frontend tsc --noEmit clean. Live 8090 on index-DAIpyDNV.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: with several folders picked, one COLLAPSE ALL folds the nested rows of
all of them, not only the row that was clicked — no top-level row is hidden
either way; scope it to the clicked folder's prefix when a column routinely
holds many picked folders.
2026-09-28 21:23:56 +07:00

837 lines
36 KiB
TypeScript

import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import {
canBrowseFolders,
ensurePermission,
getPhoto,
jumpScan,
listEditedIds,
listFolders,
listPhotos,
pickFolder,
readPhotoFile,
removeFolder,
renameFolder,
scanSession,
startScan,
stopScan,
watchScan,
type LibraryDir,
type LibraryFolder,
type LibraryPhoto,
} from './engine/library';
// 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 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: no search, rating or flag. A roll of date folders is three levels
// deep at most; add a filter once the column stops being scrollable in practice.
interface Node {
// The tree's own key: a folder name, or `folder/sub/dir`.
key: string;
label: string;
depth: number;
count: number;
}
// The two things this screen remembers between visits: the folder that was open,
// and how much room its column was given.
const NODE_KEY = 'recipescam.library.node';
const COLUMN_KEY = 'recipescam.library.column';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
// Private mode throws on localStorage rather than returning null: a screen that
// cannot remember is fine, a screen that cannot load is not.
function remembered(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function keep(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// The choice just does not outlive the visit.
}
}
export function Library() {
const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>([]);
const [ready, setReady] = useState<Set<string>>(new Set());
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
// The folders the scan in flight has walked into: a roll's own shape is on
// screen from the first pass, before the frames under its folders are read.
// What a scan found is not filed away — the next visit draws the folders that
// hold frames, and the next scan names the rest again.
const [dirs, setDirs] = useState<LibraryDir[]>([]);
const [edited, setEdited] = useState<Set<string>>(new Set());
// Which tree node is open, which frame is up, and which of the two middle
// views (the frame, or every thumbnail of the open node) is on. The open node
// is picked up where the last visit left it.
const [node, setNode] = useState<string | null>(() => remembered(NODE_KEY));
// Rows whose children are folded away. A collapse is a way of looking at the
// tree, not a property of the roll, so it is not remembered between visits.
const [folded, setFolded] = useState<Set<string>>(() => new Set());
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
// own — and shallow is what a folder of folders is for.
const [deep, setDeep] = useState(true);
const [up, setUp] = useState<string | null>(null);
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [colWidth, setColWidth] = useState(() => {
const saved = Number(remembered(COLUMN_KEY));
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
});
// The scan is the tab's, not this screen's — see `scanSession`. This screen is
// one of its watchers, and the counter is what tells React the session it is
// holding has moved on.
const live = scanSession();
const [, bump] = useReducer((n: number) => n + 1, 0);
// When the catalogue was last read back while a scan runs: the refresh reads the
// whole catalogue, so it keeps to one every few hundred milliseconds rather than
// one per frame.
const readAt = useRef(0);
const [note, setNote] = useState<string | 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; `root` means it landed on the head of a tree — the column's
// own name, or the row of a folder picked at the top level — which carries
// folding the folders nested below the top level.
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; x: number; y: number } | null>(null);
// Where the drag of the column divider started, and how wide the column was
// then — the pointer only ever reports a position, not a distance moved.
const grip = useRef<{ x: number; w: number } | null>(null);
// The catalogue is loaded once at start-up; until it is, the tree only knows
// the folders and cannot tell whether the remembered subfolder still exists.
const [loaded, setLoaded] = useState(false);
const reload = useCallback(async () => {
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
setPhotos(rows);
setEdited(edits);
}, []);
// A scan runs for as long as the roll takes, and this screen watches it rather
// than running it: the studio may be up instead, and the scan carries on. The
// names a pass has walked into are drawn as they arrive, a pass ahead of the
// frames under them; the catalogue behind the strip is read back on a timer.
useEffect(() => {
const watching = () => {
bump();
const progress = scanSession()?.progress;
if (!progress) return;
setDirs(progress.dirs);
if (progress.done === 0 || Date.now() - readAt.current > 700) {
readAt.current = Date.now();
void reload();
}
};
const unwatch = watchScan(watching);
// A scan already in flight when this screen opens is picked up where it is:
// the reader who left for the studio comes back to the same roll.
watching();
return unwatch;
}, [reload]);
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
useEffect(() => {
let alive = true;
void (async () => {
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
// The remembered folder only stands while the folder it belongs to does: a
// folder that was removed is not a folder to open on.
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
const ok = new Set<string>();
for (const folder of rows) {
if (await ensurePermission(folder.handle)) ok.add(folder.name);
}
if (!alive) return;
setReady(ok);
await reload();
if (!alive) return;
setLoaded(true);
})();
return () => {
alive = false;
};
}, [reload]);
// What the screen remembers: the folder that was open, and the width its column
// was dragged to.
useEffect(() => {
if (node) keep(NODE_KEY, node);
}, [node]);
useEffect(() => {
keep(COLUMN_KEY, String(colWidth));
}, [colWidth]);
// 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) => {
setNote(null);
try {
const result = await startScan(folder);
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
setDirs(result.dirs);
await reload();
} catch {
setNote(t('lib.failed'));
}
},
[reload, t]
);
const add = useCallback(async () => {
setNote(null);
try {
const folder = await pickFolder();
if (!folder) return;
setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name)));
setReady((prev) => new Set(prev).add(folder.name));
setNode(folder.name);
await scan(folder);
} catch (err) {
// Closing the picker is a "not now", not a failure worth a line of text.
if (err instanceof DOMException && err.name === 'AbortError') return;
setNote(t('lib.failed'));
}
}, [scan, t]);
const reconnect = useCallback(
async (folder: LibraryFolder) => {
if (await ensurePermission(folder.handle)) {
setReady((prev) => new Set(prev).add(folder.name));
await scan(folder);
} else {
setNote(t('lib.failed'));
}
},
[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);
const rest = folders.filter((f) => f.name !== folder.name);
setFolders(rest);
setDirs((prev) => prev.filter((d) => d.folder !== folder.name));
setNode(rest[0]?.name ?? null);
await reload();
},
[folders, reload]
);
// The frame goes to the studio through the address bar: the studio owns the
// load (develop, EXIF, session) and this page owns no part of it. While a scan
// is running the address bar is the one thing that changes — `go` keeps the
// reading where it is instead of reloading the page out from under it.
const open = useCallback(async (photo: LibraryPhoto) => {
const row = await getPhoto(photo.id);
if (!row) return;
// Read once here so a moved or deleted file answers with a note instead of
// a studio that opens on nothing.
try {
await readPhotoFile(row);
} catch {
setNote(t('lib.missing', { name: row.name }));
return;
}
go(`/app?lib=${encodeURIComponent(photo.id)}`);
}, [t]);
const rootName = node ? node.split('/')[0] : null;
// A row's folder as the walk spells it: '' for the picked folder itself, the
// path under it for a subfolder, and nothing for a row belonging to another
// folder — that one is not a folder of this roll.
const relOf = (key: string): string | null =>
!rootName ? null : key === rootName ? '' : key.startsWith(`${rootName}/`) ? `${key.slice(rootName.length + 1)}/` : null;
// The tree: one row per picked folder, then one per subfolder, indented by its
// depth — the folders the walk has named, whether or not a frame has been read
// out of them, each carrying how many frames sit at or under it. While a scan is
// running the count is the scan's own, so a row says how far the reading has
// come; the frames only reach the catalogue in one batch at the end, and a row
// that stood still until then would say nothing at all.
const read = live?.progress.counts ?? null;
const nodes = useMemo<Node[]>(() => {
const hits = new Map<string, number>();
const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0;
const foldersUnder = new Map<string, Set<string>>();
// One path per folder between the picked one and `rel`, so a row is drawn for
// every folder on the way to a frame as well as for the frame's own.
const add = (folder: string, rel: string, counted: boolean) => {
const set = foldersUnder.get(folder) ?? new Set<string>();
foldersUnder.set(folder, set);
let path = '';
for (const part of rel.split('/')) {
path = path ? `${path}/${part}` : part;
set.add(path);
if (counted) hits.set(`${folder}/${path}`, (hits.get(`${folder}/${path}`) ?? 0) + 1);
}
};
for (const photo of photos) {
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
if (photo.dir) add(photo.folder, photo.dir, true);
}
// A folder the walk found is a row before it holds a frame: the names arrive
// a pass ahead of the thumbnails.
for (const dir of dirs) add(dir.folder, dir.rel, false);
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
// Path order puts a folder before its own children, which is the order the
// indented rows have to come in.
for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) {
const parts = dir.split('/');
out.push({
key: `${folder.name}/${dir}`,
label: parts[parts.length - 1],
depth: parts.length,
count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)),
});
}
}
return out;
}, [dirs, folders, photos, read]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
const parents = useMemo(
() => new Set(nodes.filter((n) => n.depth > 0).map((n) => n.key.slice(0, n.key.lastIndexOf('/')))),
[nodes]
);
// The folders nested below a top-level row — what COLLAPSE ALL has to fold. A
// top-level row is not one of them: it is its own fold, since one click on its
// name shuts it, so the menu item would only repeat the row's own click.
const nested = useMemo(() => new Set([...parents].filter((key) => key.includes('/'))), [parents]);
// What the column draws: a folded row takes its whole subtree with it.
const rows = useMemo(
() =>
nodes.filter((n) => {
const parts = n.key.split('/');
for (let i = 1; i < parts.length; i++) if (folded.has(parts.slice(0, i).join('/'))) return false;
return true;
}),
[nodes, folded]
);
// The folder the open node belongs to: its name titles the column, and its
// handle is what the menu acts on.
const root = folders.find((f) => f.name === rootName) ?? null;
// A remembered subfolder that no longer holds a frame falls back to the folder
// it sits in, so the screen never opens on a node that is not drawn.
useEffect(() => {
if (!loaded || !node || nodes.some((n) => n.key === node)) return;
setNode(nodes.find((n) => n.key === node.split('/')[0])?.key ?? nodes[0]?.key ?? null);
}, [loaded, node, nodes]);
// What the open node draws: its own frames, and — unless the branch has been
// shut out of the strip — the ones in every subfolder under it, because a
// roll's top level usually holds no file of its own.
const shown = useMemo(() => {
if (!rootName) return photos;
const dir = node === rootName ? '' : (node ?? '').slice(rootName.length + 1);
const below = dir === '' ? (p: LibraryPhoto) => p.dir !== '' : (p: LibraryPhoto) => p.dir.startsWith(`${dir}/`);
return photos.filter((p) => p.folder === rootName && (p.dir === dir || (deep && below(p))));
}, [deep, node, photos, rootName]);
const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null;
const urls = useThumbUrls(shown);
// The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose
// preview the camera never wrote. A tile only carries a handler where a click
// on the picture means "open it" — in the strip the click belongs to the frame
// it raises, not to the studio.
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
urls[photo.id] ? (
<img src={urls[photo.id]} alt="" loading="lazy" onClick={onPick} title={onPick ? t('lib.open') : undefined} />
) : (
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
RAW
</div>
);
return (
<div className="adm lib-root">
<header className="adm-bar">
<a className="adm-brand" href="/">
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
<span>Recipes<em>Cam</em></span>
</a>
<b className="adm-title">{t('lib.title')}</b>
<span className="adm-spacer" />
<span className="hint" data-key="lib-count">
{t('lib.count', { n: photos.length })}
</span>
<a className="btn" data-key="lib-studio" href="/app">
{t('nav.studio')}
</a>
</header>
{note ? <p className="hint adm-note">{note}</p> : null}
{canBrowseFolders() ? (
<div className="adm-frame" data-key="lib-frame">
<div className="adm-pane">
{/* 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={!!live} onClick={() => void add()}>
{t('lib.add')}
</button>
{live ? (
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
{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">
{live ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.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>
{/* Whether the strip draws the branch under the open folder or
only the folder itself. The label says what the click does,
`on` says where the strip is now. */}
<button
type="button"
className={`chip${deep ? ' on' : ''}`}
data-key="lib-subs"
aria-pressed={deep}
title={t('lib.subsHint')}
onClick={() => setDeep((prev) => !prev)}
>
{t(deep ? 'lib.subsHide' : 'lib.subsShow')}
</button>
</>
) : null}
</div>
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
{folders.length > 0 ? (
<div className="adm-col">
<div className="adm-split" style={{ '--lib-col': `${colWidth}px` } as CSSProperties}>
{/* The shelf itself answers a right click on its empty part: the
column is the folder control, so one more folder is added
where folders are. */}
<aside
className="adm-album-list"
data-key="lib-tree"
title={t('lib.colHint')}
onContextMenu={(e) => {
if (e.target !== e.currentTarget) return;
e.preventDefault();
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) });
}}
>
{/* The column is titled with the folder the tree belongs to, so
a renamed roll is named where its frames are named. The name
is the whole tree's handle as well: one right click here
folds the folders nested below the top level. */}
<b
className="lib-col-title"
data-key="lib-root-name"
title={`${root?.label || root?.name} — ${t('lib.rootHint')}`}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ folder: null, root: true, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) });
}}
>
{root?.label || root?.name}
</b>
{rows.map((n) => {
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
// A roll being read says so on its own row: while a scan runs
// the toolbar's progress line is nowhere near the eye.
const scanning = !!folder && live?.folder === folder.name;
const under = parents.has(n.key);
return (
<button
key={n.key}
type="button"
className={`adm-album${node === n.key ? ' on' : ''}`}
data-key={`lib-node-${n.key}`}
aria-current={node === n.key}
aria-busy={scanning}
aria-expanded={under ? !folded.has(n.key) : undefined}
title={t('lib.nodeHint')}
style={{ paddingLeft: 9 + n.depth * 12 }}
// A row with children opens on the folder and folds it in
// the same click: the frames below stay in the strip, only
// the rows go.
onClick={() => {
setNode(n.key);
// The folder just clicked is the one a scan in flight
// reads next, whatever layer it sits in.
jumpScan(relOf(n.key));
if (!under) return;
setFolded((prev) => {
const next = new Set(prev);
if (next.has(n.key)) next.delete(n.key);
else next.add(n.key);
return next;
});
}}
// A folder's own menu: rename, scan again, forget. All three
// act on the folder that was picked, so a right click on a
// subfolder row brings up its roll's menu. A row at the top
// level is a whole tree's head, so it also carries folding
// what sits under it — the item the column's own name has,
// where a reader actually right-clicks. Shift+F10 raises
// the same event, which is the keyboard's way in.
onContextMenu={(e) => {
if (!folder) return;
e.preventDefault();
const box = e.currentTarget.getBoundingClientRect();
setMenu({
folder,
root: n.depth === 0,
x: Math.min(e.clientX || box.left, window.innerWidth - 190),
y: Math.min(e.clientY || box.top, window.innerHeight - 96),
});
}}
>
<span
className={`lib-caret${under ? (folded.has(n.key) ? '' : ' open') : ' leaf'}`}
aria-hidden="true"
/>
<span className="adm-album-name">{n.label}</span>
{scanning ? <span className="lib-spin" aria-hidden="true" /> : null}
<span className="adm-album-count">{n.count}</span>
</button>
);
})}
</aside>
<div
className="lib-grip"
data-key="lib-grip"
role="separator"
aria-orientation="vertical"
aria-label={t('lib.colWidth')}
aria-valuenow={colWidth}
aria-valuemin={COLUMN_MIN}
aria-valuemax={COLUMN_MAX}
tabIndex={0}
title={t('lib.colWidth')}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
grip.current = { x: e.clientX, w: colWidth };
}}
onPointerMove={(e) => {
const held = grip.current;
if (!held) return;
// 88px is the narrowest a name and its count still read at,
// 460 the widest the stage can lose and stay a stage.
setColWidth(Math.round(Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, held.w + e.clientX - held.x))));
}}
onPointerUp={(e) => {
grip.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
onKeyDown={(e) => {
const step = e.key === 'ArrowLeft' ? -12 : e.key === 'ArrowRight' ? 12 : 0;
if (!step) return;
e.preventDefault();
setColWidth((w) => Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, w + step)));
}}
/>
<section className="adm-stage" data-key="lib-stage">
{view === 'grid' ? (
<div className="adm-grid">
{shown.map((photo) => (
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
{tile(photo, () => void open(photo))}
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
{t('lib.open')}
</button>
</article>
))}
</div>
) : frame ? (
<>
{urls[frame.id] ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
src={urls[frame.id]}
alt=""
onClick={() => void open(frame)}
title={t('lib.open')}
/>
) : (
<div className="lib-noimg" onClick={() => void open(frame)} title={t('lib.open')}>
RAW
</div>
)}
<div className="adm-meta" data-key="lib-stage-meta">
<b>{frame.name}</b>
<span>{frame.dir || node}</span>
<span>
{new Date(frame.taken).toLocaleString()} · {t('lib.size', { mb: (frame.size / 1048576).toFixed(1) })}
{edited.has(frame.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
<div className="chip-row">
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
{t('lib.open')}
</button>
</div>
</>
) : (
<p className="hint adm-row-empty">{t('lib.empty')}</p>
)}
</section>
</div>
{/* The strip: one thumbnail per frame the open node holds, in the
order the catalogue reads them (newest shutter first). */}
{view === 'preview' ? (
<div
className="adm-filmstrip"
data-key="lib-strip"
// A wheel has no sideways axis, so a plain tick over the strip
// walks the frames along it. Nothing else there can scroll.
onWheel={(e) => {
if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY;
}}
>
{shown.map((photo) => (
<button
key={photo.id}
type="button"
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}`}
data-key={`lib-thumb-${photo.id}`}
title={photo.name}
onClick={() => setUp(photo.id)}
>
{tile(photo)}
<span className="adm-thumb-cap">{photo.name}</span>
</button>
))}
{shown.length === 0 ? <p className="hint adm-row-empty">{t('lib.empty')}</p> : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
) : (
<p className="adm-note hint">{t('lib.unsupported')}</p>
)}
{/* The context menu: a folder gets what belongs to a folder rather than to a
frame, the empty part of the column gets the folder that is missing, and
the head of the tree gets the whole tree folded. A folder whose
permission has lapsed gets the one thing 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()}>
{/* Fold-the-tree leads the menu of a top-level row, and stands alone on
the column's own name: the folders nested below the top level go,
one line per folder, and no top-level row ever leaves the column —
the row itself is one click away and its click already shuts it. */}
{menu.root ? (
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-collapse"
disabled={nested.size === 0}
onClick={() => {
setMenu(null);
setFolded(new Set(nested));
}}
>
{t('lib.collapseAll')}
</button>
) : null}
{menu.folder ? (
ready.has(menu.folder.name) ? (
<>
<button
type="button"
className="lib-menu-item"
data-key={`lib-rename-${menu.folder.name}`}
onClick={() => {
setMenu(null);
void rename(menu.folder!);
}}
>
{t('lib.rename')}
</button>
<button
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={!!live}
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={!!live}
onClick={() => {
setMenu(null);
void reconnect(menu.folder!);
}}
>
{t('lib.reconnect')}
</button>
)
) : menu.root ? null : (
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-add"
disabled={!!live}
onClick={() => {
setMenu(null);
void add();
}}
>
{t('lib.add')}
</button>
)}
</div>
) : null}
</div>
);
}
// One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and `shown` is the only list
// the panels paint.
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
useEffect(() => {
const next: Record<string, string> = {};
for (const photo of photos) if (photo.thumb) next[photo.id] = URL.createObjectURL(photo.thumb);
setUrls(next);
return () => {
for (const url of Object.values(next)) URL.revokeObjectURL(url);
};
}, [photos]);
return urls;
}