web: browse the catalogue as a tree of folders
The library page was a flat wall of thumbnails. It now reads like the admin pictures tab: a folder tree on the left, the picked node's frame on the stage, that node's frames in the strip below, and a chip pair to swap the stage for a wall of every thumbnail in the node. Folders are walked recursively (dot/@-prefixed names skipped, an unreadable subfolder is dropped rather than failing the scan), so a roll with a dated subfolder keeps both levels. Node counts include the subfolders underneath.
This commit is contained in:
+241
-80
@@ -16,21 +16,35 @@ import {
|
||||
type ScanProgress,
|
||||
} from './engine/library';
|
||||
|
||||
// The catalogue screen: folders from the visitor's own disk, a tile per frame,
|
||||
// and a recipe remembered per frame. Nothing is uploaded — the page never holds
|
||||
// more than the thumbnails, and the RAW itself is read only when a frame is
|
||||
// opened in the studio (`/app?lib=<id>`).
|
||||
// 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>`).
|
||||
//
|
||||
// ponytail: no rating, no flags, no collections, no search. IndexedDB already
|
||||
// holds the fields a few of those would need (folder, taken); add them once the
|
||||
// catalogue is big enough that scrolling it stops working.
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function Library() {
|
||||
const { t } = useI18n();
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
|
||||
const [edited, setEdited] = useState<Set<string>>(new Set());
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
// 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.
|
||||
const [node, setNode] = useState<string | null>(null);
|
||||
const [up, setUp] = useState<string | null>(null);
|
||||
const [view, setView] = useState<'preview' | 'grid'>('preview');
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -51,6 +65,7 @@ export function Library() {
|
||||
const rows = await listFolders();
|
||||
if (!alive) return;
|
||||
setFolders(rows);
|
||||
setNode((prev) => prev ?? rows[0]?.name ?? null);
|
||||
const ok = new Set<string>();
|
||||
for (const folder of rows) {
|
||||
if (await ensurePermission(folder.handle)) ok.add(folder.name);
|
||||
@@ -90,7 +105,7 @@ export function Library() {
|
||||
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));
|
||||
setPick(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.
|
||||
@@ -114,11 +129,12 @@ export function Library() {
|
||||
const drop = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
await removeFolder(folder.name);
|
||||
setFolders((prev) => prev.filter((f) => f.name !== folder.name));
|
||||
if (pick === folder.name) setPick(null);
|
||||
const rest = folders.filter((f) => f.name !== folder.name);
|
||||
setFolders(rest);
|
||||
setNode(rest[0]?.name ?? null);
|
||||
await reload();
|
||||
},
|
||||
[pick, reload]
|
||||
[folders, reload]
|
||||
);
|
||||
|
||||
// The frame goes to the studio through the address bar: the studio owns the
|
||||
@@ -137,9 +153,69 @@ export function Library() {
|
||||
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
|
||||
}, [t]);
|
||||
|
||||
const shown = useMemo(() => (pick ? photos.filter((p) => p.folder === pick) : photos), [photos, pick]);
|
||||
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.
|
||||
const nodes = useMemo<Node[]>(() => {
|
||||
const hits = new Map<string, number>();
|
||||
const dirs = new Map<string, Set<string>>();
|
||||
for (const photo of photos) {
|
||||
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
|
||||
if (!photo.dir) continue;
|
||||
const set = dirs.get(photo.folder) ?? new Set<string>();
|
||||
dirs.set(photo.folder, set);
|
||||
let path = '';
|
||||
for (const part of photo.dir.split('/')) {
|
||||
path = path ? `${path}/${part}` : part;
|
||||
set.add(path);
|
||||
hits.set(`${photo.folder}/${path}`, (hits.get(`${photo.folder}/${path}`) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const out: Node[] = [];
|
||||
for (const folder of folders) {
|
||||
out.push({ key: folder.name, label: folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
|
||||
// Path order puts a folder before its own children, which is the order the
|
||||
// indented rows have to come in.
|
||||
const own = [...(dirs.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b));
|
||||
for (const dir of own) {
|
||||
const parts = dir.split('/');
|
||||
out.push({
|
||||
key: `${folder.name}/${dir}`,
|
||||
label: parts[parts.length - 1],
|
||||
depth: parts.length,
|
||||
count: hits.get(`${folder.name}/${dir}`) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [folders, photos]);
|
||||
|
||||
// What the open node draws: its own frames, and the ones in every subfolder
|
||||
// under it — 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);
|
||||
return photos.filter((p) => p.folder === rootName && (dir === '' || p.dir === dir || p.dir.startsWith(`${dir}/`)));
|
||||
}, [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">
|
||||
<header className="adm-bar">
|
||||
@@ -179,82 +255,167 @@ export function Library() {
|
||||
|
||||
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
|
||||
|
||||
<div className="chip-row" style={{ marginBottom: 12 }}>
|
||||
{folders.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={pick === null ? 'chip on' : 'chip'}
|
||||
data-key="lib-folder-all"
|
||||
onClick={() => setPick(null)}
|
||||
>
|
||||
{t('lib.all')}
|
||||
</button>
|
||||
) : null}
|
||||
{folders.map((folder) => (
|
||||
<span key={folder.name} className="chip-row">
|
||||
{folders.length > 0 ? (
|
||||
<div className="adm-col">
|
||||
<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={pick === folder.name ? 'chip on' : 'chip'}
|
||||
data-key={`lib-folder-${folder.name}`}
|
||||
onClick={() => setPick(folder.name)}
|
||||
className={`chip${view === 'preview' ? ' on' : ''}`}
|
||||
data-key="lib-view-preview"
|
||||
aria-pressed={view === 'preview'}
|
||||
onClick={() => setView('preview')}
|
||||
>
|
||||
{folder.name}
|
||||
{t('lib.viewPreview')}
|
||||
</button>
|
||||
{ready.has(folder.name) ? (
|
||||
<>
|
||||
<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 ghost"
|
||||
data-key={`lib-rescan-${folder.name}`}
|
||||
className="btn"
|
||||
data-key={`lib-reconnect-${selected.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void scan(folder)}
|
||||
onClick={() => void reconnect(selected)}
|
||||
>
|
||||
{t('lib.rescan')}
|
||||
{t('lib.reconnect')}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" data-key={`lib-drop-${folder.name}`} onClick={() => void drop(folder)}>
|
||||
{t('lib.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`lib-reconnect-${folder.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void reconnect(folder)}
|
||||
>
|
||||
{t('lib.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 && !busy ? <p className="hint adm-note">{t('lib.empty')}</p> : null}
|
||||
|
||||
<div className="adm-grid">
|
||||
{shown.map((photo) => (
|
||||
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
||||
{urls[photo.id] ? (
|
||||
<img src={urls[photo.id]} alt="" loading="lazy" onClick={() => void open(photo)} title={t('lib.open')} />
|
||||
) : (
|
||||
<div className="lib-noimg" onClick={() => void open(photo)} title={t('lib.open')}>
|
||||
RAW
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<div className="adm-split">
|
||||
<aside className="adm-album-list" data-key="lib-tree">
|
||||
{nodes.map((n) => (
|
||||
<button
|
||||
key={n.key}
|
||||
type="button"
|
||||
className={`adm-album${node === n.key ? ' on' : ''}`}
|
||||
data-key={`lib-node-${n.key}`}
|
||||
aria-current={node === n.key}
|
||||
style={{ paddingLeft: 9 + n.depth * 14 }}
|
||||
onClick={() => setNode(n.key)}
|
||||
>
|
||||
<span className="adm-album-name">{n.label}</span>
|
||||
<span className="adm-album-count">{n.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
) : (
|
||||
@@ -266,7 +427,7 @@ export function Library() {
|
||||
|
||||
// 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 grid paints.
|
||||
// the panels paint.
|
||||
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
|
||||
const [urls, setUrls] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user