Files
RecipesCam/docker/frontend/src/Library.tsx
T
3dtours 35d8d57984 HSL: click a colour chip to open its panel, the way PICK does
The colour band chips only aimed the mixer before; now they open the same
panel the eyedropper opens, seeded with the colour the chip is named after
(hexToRgb turns that readout back into the RGB bytes the picker stores).
A pick already on the photo keeps its spot — only a mixer with nowhere to
hang takes the middle of the frame.

The HSL column itself is untouched: 8 chips, the rule, IMAGE,
HUE/SAT/LUM, the readout and RESET all stay where they were.
2026-09-30 07:34:07 +07:00

1130 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import {
canBrowseFolders,
ensurePermission,
getPhoto,
HEAD_BYTES,
jumpScan,
listEditedIds,
listFolders,
listPhotos,
pickFolder,
readPhotoFile,
removeFolder,
renameFolder,
scanSession,
setStar,
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 and no flag. A roll of date folders is three levels deep at
// most, and what a reader actually digs through a shelf with is the score they
// gave a frame, the year it was shot and the hours they shot it in — which are
// the three the thumbnail view filters on. Add the text box when a folder of
// mixed names makes one worth typing into.
interface Node {
// The tree's own key: a folder name, or `folder/sub/dir`.
key: string;
label: string;
depth: number;
count: number;
}
// What this screen remembers between visits: the folder that was open, the frame
// that was raised in it, how much room its column was given, and which folders of
// the tree were drawn open.
const NODE_KEY = 'recipescam.library.node';
const FRAME_KEY = 'recipescam.library.frame';
const COLUMN_KEY = 'recipescam.library.column';
const OPEN_KEY = 'recipescam.library.open';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
// How far a wheel tick over the frame magnifies it, and where it stops: past a
// handful of ticks the thumbnail behind the preview runs out of pixels anyway.
const ZOOM_MAX = 6;
const ZOOM_STEP = 1.15;
// The hours a frame's own clock can read, and what the time filter offers: the
// whole day in the 24-hour form a camera writes into EXIF.
const HOURS = Array.from({ length: 24 }, (_, i) => i);
// What the frame's own file says about itself: the line of numbers the camera
// recorded, and where it was shot. Both sit in the EXIF at the head of the file,
// so the frame costs one read of its first few hundred kilobytes rather than the
// whole negative — and only the raised frame ever pays it.
interface Shot {
line: string;
lat: number | null;
lng: number | null;
// The geocoder's name for those coordinates, or null: naming a place is a
// network round trip on a PRO account, and a refusal leaves the numbers.
place: string | null;
}
// The frames already read this visit, by the read itself: the catalogue is read
// back on a timer while a scan runs, which hands this screen a new frame object
// every few hundred milliseconds, so what keeps the file from being read again is
// the read in flight rather than the object under it.
const shots = new Map<string, Promise<Shot | null>>();
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>(() => recall(NODE_KEY));
// The rows whose children are drawn. The column opens with every folder shut —
// a roll is a shelf of shelves and its whole shape is not what the screen is
// for — and remembers the ones the reader opened, so the next visit draws the
// tree the way the last one was left.
const [expanded, setExpanded] = useState<Set<string>>(() => {
const saved = recall(OPEN_KEY);
// Nothing remembered means this is the first visit under that rule, and the
// node the last visit left would then open behind a shut row: its own rows
// are opened for it. A visit that folded everything remembers the empty set,
// which is not this, and is drawn as it was left.
if (saved === null) {
const parts = (recall(NODE_KEY) ?? '').split('/');
return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
}
return new Set(saved.split(',').filter(Boolean));
});
// 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);
// The frame that is up in the open node — raised by a click on its thumbnail,
// and picked up where the last visit left it, so a reader who leaves for the
// studio comes back to the negative they were looking at rather than to the
// first one the node happens to hold. A frame that is gone falls through to
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
const [view, setView] = useState<'preview' | 'grid'>('preview');
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
// these hours — and in the order the reader picked. A way of looking, not part
// of the roll, so none of it outlives the visit; the strip below the frame does
// not follow it, because the strip is the shelf itself.
const [order, setOrder] = useState<'newest' | 'oldest' | 'star'>('newest');
const [minStar, setMinStar] = useState(0);
const [year, setYear] = useState('all');
const [from, setFrom] = useState('any');
const [to, setTo] = useState('any');
// How far the frame is magnified, and the point it is magnified about: the one
// under the pointer, so the picture grows around what is being looked at.
const [zoom, setZoom] = useState(1);
const [zoomAt, setZoomAt] = useState('50% 50%');
const [colWidth, setColWidth] = useState(() => {
const saved = Number(recall(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 a row at the top level — the head
// of a tree — which also carries folding the whole tree.
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, the frame that was up in
// it, and the width its column was dragged to.
useEffect(() => {
if (node) keep(NODE_KEY, node);
}, [node]);
useEffect(() => {
if (up) keep(FRAME_KEY, up);
}, [up]);
useEffect(() => {
keep(COLUMN_KEY, String(colWidth));
}, [colWidth]);
useEffect(() => {
keep(OPEN_KEY, [...expanded].join(','));
}, [expanded]);
// 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, quiet = false) => {
setNote(null);
try {
const result = await startScan(folder);
if (!quiet) 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]
);
// The folder this screen is on is read back to the disk on the way in, and again
// every time the tab is raised. A frame that has not moved costs a name, a size
// and a write time and nothing else, so the walk is cheap — and cheap for the
// two things the reader keeps running into: a roll whose first reading ended
// when the app did (nothing of it was lost, and nothing finished it either), and
// frames that landed in the folder since. No note: the ring on the row and the
// progress line are the report.
const rootKey = node ? node.split('/')[0] : null;
useEffect(() => {
const root = folders.find((f) => f.name === rootKey);
if (!loaded || !root || !ready.has(root.name)) return;
const catchUp = () => {
if (!scanSession()) void scan(root, true);
};
catchUp();
const raised = () => {
if (document.visibilityState === 'visible') catchUp();
};
document.addEventListener('visibilitychange', raised);
return () => document.removeEventListener('visibilitychange', raised);
// `folders` and `ready` are read here, not watched: the two only ever gain a
// name or a permission, and neither is a reason to read the disk again.
}, [loaded, rootKey, scan]);
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]
);
// What the column draws: a row whose parent is not open goes with it.
const rows = useMemo(
() =>
nodes.filter((n) => {
const parts = n.key.split('/');
for (let i = 1; i < parts.length; i++) if (!expanded.has(parts.slice(0, i).join('/'))) return false;
return true;
}),
[nodes, expanded]
);
// 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 years the open folder holds, newest first: the filter offers the years
// there are frames to find in rather than a century of empty ones.
const years = useMemo(
() => [...new Set(photos.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a),
[photos]
);
// What the thumbnail view draws, out of what the open node holds. A frame's
// hour is the hour the shutter fired in, on this machine's clock — the same
// reading the frame's own date line prints, so 16:00–18:00 is the afternoon
// the reader was out and not an offset of it.
const listed = useMemo(() => {
const rows = shown.filter((p) => {
if (minStar && (p.star ?? 0) < minStar) return false;
const at = new Date(p.taken);
if (year !== 'all' && at.getFullYear() !== Number(year)) return false;
if (from !== 'any' && at.getHours() < Number(from)) return false;
if (to !== 'any' && at.getHours() > Number(to)) return false;
return true;
});
// `shown` is already newest shutter first, so the oldest is the same list
// read backwards; the score comes first in the third, and the shutter breaks
// its ties the way the shelf does.
if (order === 'star') return [...rows].sort((a, b) => (b.star ?? 0) - (a.star ?? 0) || b.taken - a.taken);
return order === 'oldest' ? [...rows].reverse() : rows;
}, [shown, minStar, year, from, to, order]);
// A frame's score: click a star to give it, click the star it already has to
// take the score back. The catalogue holds it, so it is the same score the next
// visit reads — and the list on screen is told, rather than read back whole for
// one number.
const rate = useCallback(async (photo: LibraryPhoto, n: number) => {
await setStar(photo.id, n);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star: n } : p)));
}, []);
const [shot, setShot] = useState<Shot | null>(null);
// Where the frame was shot, as the coordinates it carries — the fallback for a
// file whose place the geocoder could not name.
const coords = shot && shot.lat !== null && shot.lng !== null ? `${shot.lat.toFixed(4)}, ${shot.lng.toFixed(4)}` : null;
// The two lines the stage prints under the frame. The numbers come off the
// file's own head, so what is on screen is what the camera wrote and nothing
// this page invented; the place name is a network round trip that lands after
// them, and one that is refused leaves the coordinates standing.
useEffect(() => {
if (!frame) return;
const id = frame.id;
let live = true;
let read = shots.get(id);
if (!read) {
read = (async () => {
try {
const head = new Uint8Array(await (await readPhotoFile(frame)).slice(0, HEAD_BYTES).arrayBuffer());
const [specs, gps] = await Promise.all([readSpecs(head), readGps(head)]);
const shot: Shot = { line: specsLine(specs), lat: gps?.latitude ?? null, lng: gps?.longitude ?? null, place: null };
if (shot.lat !== null && shot.lng !== null) {
// Naming a place is a round trip on a PRO account; the numbers do not
// wait for it, and a refusal or a miss simply leaves them alone.
void api
.place(shot.lat, shot.lng)
.then(({ place }) => {
if (!place) return;
shot.place = place;
if (live) setShot({ ...shot });
})
.catch(() => {});
}
return shot;
} catch {
// A file that cannot be read says nothing about itself.
return null;
}
})();
shots.set(id, read);
}
void read.then((shot) => {
if (live) setShot(shot);
});
return () => {
live = false;
};
}, [frame?.id]);
// A frame that has just come up is fitted: the zoom belonged to the negative
// that was up before it.
useEffect(() => {
setZoom(1);
}, [frame?.id]);
// A wheel tick over the frame is the zoom — a negative is the one thing on this
// screen worth filling the window with — and the point under the pointer is the
// point that stays put, which is how a reader aims at what to magnify. The
// listener goes on the element rather than through `onWheel`, because React's
// own wheel is passive and this one has to hold the page still while it zooms.
// It is not taken off again: it lives on the element, and the element leaves
// with it (the guard keeps a re-attach off the same node, which is what a
// double-invoked render would otherwise do).
const frameRef = useCallback((el: HTMLImageElement | null) => {
if (!el || el.dataset.zoom === 'bound') return;
el.dataset.zoom = 'bound';
el.addEventListener(
'wheel',
(e) => {
// A tick with no vertical axis (a wheel pushed sideways) is not a zoom.
if (!e.deltaY) return;
e.preventDefault();
const box = el.getBoundingClientRect();
// A tick is a notch, and a trackpad sends its notches in small pieces: the
// step is taken from how far the wheel turned rather than assumed, so both
// turn into the same travel under the pointer.
const notches = Math.max(-3, Math.min(3, -e.deltaY / 100));
setZoomAt(`${(((e.clientX - box.left) / box.width) * 100).toFixed(2)}% ${(((e.clientY - box.top) / box.height) * 100).toFixed(2)}%`);
setZoom((z) => Math.min(ZOOM_MAX, Math.max(1, z * ZOOM_STEP ** notches)));
},
{ passive: false }
);
}, []);
// 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)}
>
<span className="chip-label">{t(deep ? 'lib.subsHide' : 'lib.subsShow')}</span>
</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) });
}}
>
{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 ? expanded.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;
setExpanded((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, 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 ? (expanded.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' ? (
<>
{/* What the shelf is read through. It holds every frame the
open node does — a filter is a way of looking, not a
second shelf — and the line at its end says how many
are left, so a filter that finds nothing says so. */}
<div className="chip-row lib-filters">
<select
className="adm-input"
data-key="lib-sort"
aria-label={t('lib.sort')}
value={order}
onChange={(e) => setOrder(e.target.value as 'newest' | 'oldest' | 'star')}
>
<option value="newest">{t('lib.sortNewest')}</option>
<option value="oldest">{t('lib.sortOldest')}</option>
<option value="star">{t('lib.sortStars')}</option>
</select>
<select
className="adm-input"
data-key="lib-filter-star"
aria-label={t('lib.rating')}
value={String(minStar)}
onChange={(e) => setMinStar(Number(e.target.value))}
>
<option value="0">{t('lib.ratingAll')}</option>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={String(n)}>
{t('lib.ratingAtLeast', { n })}
</option>
))}
</select>
<select
className="adm-input"
data-key="lib-filter-year"
aria-label={t('lib.year')}
value={year}
onChange={(e) => setYear(e.target.value)}
>
<option value="all">{t('lib.yearAll')}</option>
{years.map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
{/* The two ends of the hours: 16:00 to 18:00 is the
afternoon the reader was out, and either end on its
own is "from 16:00" or "up to 18:00". */}
<select
className="adm-input"
data-key="lib-filter-from"
aria-label={t('lib.hourFrom')}
value={from}
onChange={(e) => setFrom(e.target.value)}
>
<option value="any">{t('lib.hourFromAny')}</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{`${String(h).padStart(2, '0')}:00`}
</option>
))}
</select>
<select
className="adm-input"
data-key="lib-filter-to"
aria-label={t('lib.hourTo')}
value={to}
onChange={(e) => setTo(e.target.value)}
>
<option value="any">{t('lib.hourToAny')}</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{`${String(h).padStart(2, '0')}:00`}
</option>
))}
</select>
<span className="hint" data-key="lib-listed">
{t('lib.count', { n: listed.length })}
</span>
</div>
<div className="adm-grid">
{listed.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>
{photo.star ? `★${photo.star} · ` : ''}
{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"
ref={frameRef}
src={urls[frame.id]}
alt=""
onClick={() => void open(frame)}
title={t('lib.open')}
style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }}
/>
) : (
<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>
{shot?.place || coords ? (
<span data-key="lib-stage-place" title={coords ?? undefined}>
{shot?.place ?? coords}
</span>
) : null}
{shot?.line ? <span data-key="lib-stage-specs">{shot.line}</span> : null}
</div>
<div className="chip-row">
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
{t('lib.open')}
</button>
<span className="adm-spacer" />
{/* The frame's own score, and the only place one is
given: the star that is lit is the score it has,
and the star under the click is the score it gets —
the one it already has takes the score back. */}
<div className="lib-stars" data-key="lib-stars" role="group" aria-label={t('lib.rating')}>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(frame.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${n}`}
aria-pressed={(frame.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(frame, (frame.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
</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 row at the top level, and is the
way back to the column's rest: every row shut, the top-level one
included, which is the shape the column opens in. A row's own click
still draws its subtree and folds it again, so this is for a tree
that was walked into rather than for one row. */}
{menu.root ? (
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-collapse"
disabled={expanded.size === 0}
onClick={() => {
setMenu(null);
setExpanded(new Set());
}}
>
<span className="lib-menu-icon" aria-hidden="true">▴</span>
{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!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">✎</span>
{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!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{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!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">✕</span>
{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!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">⚿</span>
{t('lib.reconnect')}
</button>
)
) : (
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-add"
disabled={!!live}
onClick={() => {
setMenu(null);
void add();
}}
>
<span className="lib-menu-icon" aria-hidden="true">+</span>
{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;
}