2078 lines
95 KiB
TypeScript
2078 lines
95 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } 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 { isRawName, rawThumbnail } from './engine/rawDevelop';
|
||
import {
|
||
cachedPhotos,
|
||
canBrowseFolders,
|
||
clearLibraryCache,
|
||
ensurePermission,
|
||
findFolderRelationship,
|
||
getPhoto,
|
||
getPhotoThumbs,
|
||
HEAD_BYTES,
|
||
jumpScan,
|
||
listEditedIds,
|
||
listFolders,
|
||
pickFolder,
|
||
readPhotoFile,
|
||
readPhotos,
|
||
reconnectPhotosForFolders,
|
||
removeFolder,
|
||
renameFolder,
|
||
scanAsked,
|
||
scanBusy,
|
||
scanQueue,
|
||
scanSession,
|
||
setStar,
|
||
stopScan,
|
||
stopScanAt,
|
||
unfinished,
|
||
watchRows,
|
||
watchScan,
|
||
type LibraryDir,
|
||
type LibraryFolder,
|
||
type LibraryPhoto,
|
||
type ScanMode,
|
||
} from './engine/library';
|
||
import {
|
||
backupNow,
|
||
backupStatus,
|
||
pickBackupFolder,
|
||
restoreNow,
|
||
type BackupStatus,
|
||
} from './engine/libraryBackup';
|
||
|
||
// 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 DEEP_KEY = 'recipescam.library.deep';
|
||
const VIEW_KEY = 'recipescam.library.view';
|
||
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 strip's tile: `.adm-thumb` is 132px wide and `.adm-filmstrip` leaves 8px
|
||
// between tiles, so a tile takes 140px of the strip wherever it sits.
|
||
const TILE_STEP = 140;
|
||
// How many tiles past each edge of the strip are drawn. Enough that a fast drag
|
||
// meets pictures rather than placeholders, few enough that the count does not
|
||
// matter: what is drawn is this many, whatever the shelf holds.
|
||
const STRIP_KEEP = 8;
|
||
// How many rows of the wall past each edge of its viewport are drawn. A row of
|
||
// cards is a couple of hundred pixels tall, so this is the row under the eye and
|
||
// two beyond it — enough that a flick lands on pictures.
|
||
const WALL_ROWS = 2;
|
||
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
|
||
// distance past the last row it replaces as the row itself would have.
|
||
const WALL_GAP = 12;
|
||
// 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_MAX = 50;
|
||
const shots = new Map<string, Promise<Shot | null>>();
|
||
|
||
function keepShot(id: string, promise: Promise<Shot | null>): void {
|
||
if (shots.size >= SHOTS_MAX) {
|
||
const oldestKey = shots.keys().next().value;
|
||
if (oldestKey) shots.delete(oldestKey);
|
||
}
|
||
shots.set(id, promise);
|
||
}
|
||
|
||
// How often a running scan is allowed to have its catalogue read back, and how
|
||
// often the tab may walk the folder again just for being raised. Both are the
|
||
// same trade — the reading is watched, not driven — and both are seconds for the
|
||
// same reason: the roll is the long thing, and neither the strip nor a folder
|
||
// that has not moved is worth a frame of it.
|
||
const CATALOGUE_MS = 5000;
|
||
const CATCHUP_MS = 30000;
|
||
|
||
// The two refusals the write comes back with when the folder is not there or the
|
||
// browser is not letting this page write to it: the names `libraryBackup` throws
|
||
// them by, and the ones the picker is the answer to.
|
||
const needsPick = (err: unknown) =>
|
||
err instanceof Error && (err.message === 'no-backup-permission' || err.message === 'no-backup-folder');
|
||
|
||
// A word for the note's parenthesis: the name the browser gave the refusal, or
|
||
// the short name the backup threw, and never a stack.
|
||
const why = (err: unknown) => {
|
||
if (err instanceof Error && err.message.startsWith('no-backup-')) return err.message.replace('no-backup-', '');
|
||
if (typeof DOMException !== 'undefined' && err instanceof DOMException) return err.name;
|
||
return err instanceof Error ? err.message : String(err);
|
||
};
|
||
|
||
|
||
|
||
export function Library() {
|
||
const { t } = useI18n();
|
||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||
// The strip starts on the catalogue the last screen read: coming back from the
|
||
// studio is a screen opening, not a page loading, and the frames the reader was
|
||
// looking at are already in hand. The read below replaces them a moment later.
|
||
const [photos, setPhotos] = useState<LibraryPhoto[]>(() => cachedPhotos());
|
||
// 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);
|
||
const nodeParts = (recall(NODE_KEY) ?? '').split('/');
|
||
const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/'));
|
||
|
||
if (saved === null) {
|
||
return new Set(nodeParents);
|
||
}
|
||
const set = new Set(saved.split(',').filter(Boolean));
|
||
for (const parent of nodeParents) set.add(parent);
|
||
return 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. The reader's answer is
|
||
// picked up where the last visit left it, the same as the open node above.
|
||
const [deep, setDeep] = useState(() => recall(DEEP_KEY) !== 'false');
|
||
// 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'>(() => (recall(VIEW_KEY) === 'grid' ? '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);
|
||
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; rel?: string; node?: string; x: number; y: number } | null>(null);
|
||
const [photoMenu, setPhotoMenu] = useState<{ photo: LibraryPhoto; x: number; y: number } | null>(null);
|
||
// A reading of this roll that is still waiting its turn. The menu is where
|
||
// that wait is called off, so it has to know one exists.
|
||
const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false;
|
||
// 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);
|
||
|
||
// The folder the catalogue is copied into, and whether the browser is still
|
||
// letting this page write to it — which it stops doing when the tab closes, so
|
||
// the row below says so rather than letting a write fail quietly. The ref is
|
||
// the same value for the scan that ends behind this screen's back: it reads it
|
||
// without being subscribed to it.
|
||
const [back, setBack] = useState<BackupStatus | null>(null);
|
||
const backRef = useRef<BackupStatus | null>(null);
|
||
const [backBusy, setBackBusy] = useState(false);
|
||
|
||
const reload = useCallback(async () => {
|
||
const [rows, edits] = await Promise.all([readPhotos(), listEditedIds()]);
|
||
// A read that came back with nothing is a read that failed, not a catalogue
|
||
// that emptied — and the frames it would have cleared are frames the reader
|
||
// was working their way through. The strip keeps them.
|
||
if (rows) setPhotos(rows);
|
||
setEdited(edits);
|
||
}, []);
|
||
|
||
// --- the catalogue's own copy ---------------------------------------------
|
||
|
||
const show = useCallback(async (status: BackupStatus) => {
|
||
backRef.current = status;
|
||
setBack(status);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
void backupStatus().then((status) => {
|
||
if (alive) void show(status);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [show]);
|
||
|
||
// Open the picker and take what it chose. False when the reader closed it, or
|
||
// when there is no picker to open — and the one thing this is asked for is the
|
||
// thing a folder cannot be reached without: it is where a folder is chosen,
|
||
// where a remembered one is handed its permission back, and where a backup is
|
||
// named, since the name of the folder is the name of the backup.
|
||
const pickBackup = useCallback(async (): Promise<boolean> => {
|
||
try {
|
||
await pickBackupFolder();
|
||
await show(await backupStatus());
|
||
return true;
|
||
} 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 false;
|
||
setNote(t('lib.failed'));
|
||
return false;
|
||
}
|
||
}, [show, t]);
|
||
|
||
// Write the catalogue into the folder. With none picked yet this is also the
|
||
// way to pick one: one button, because a backup folder is not something to
|
||
// choose and then not use. `quiet` is the run that follows a scan — it says
|
||
// nothing, since a note about a backup would sit over the note about the scan.
|
||
const backup = useCallback(
|
||
async (quiet = false) => {
|
||
if (backBusy) return;
|
||
// A folder the browser has not let this page write to is a folder to pick
|
||
// again, and the picker is the only thing that can hand the permission
|
||
// back — a permission outlives the tab only while the tab does, so this is
|
||
// the run that follows every restart. Without it the button wrote nothing
|
||
// and said so, which is a backup that quietly stopped happening. A quiet
|
||
// run never comes here: it is guarded by the permission it would ask for.
|
||
if ((!backRef.current?.folder || !backRef.current.granted) && !(await pickBackup())) return;
|
||
setBackBusy(true);
|
||
if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 }));
|
||
try {
|
||
for (let attempt = 0; ; attempt++) {
|
||
try {
|
||
const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total })));
|
||
await show(await backupStatus());
|
||
if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written }));
|
||
break;
|
||
} catch (err) {
|
||
// What the row was reading is the permission of a moment ago, and
|
||
// the browser is free to want the click again by the time the write
|
||
// reaches the disk — asking for it again from here is a request
|
||
// without one behind it, and a refusal. The picker is the only
|
||
// thing that hands a folder back, so a refusal that names the
|
||
// permission goes through it once; anything else is the folder
|
||
// itself saying no, and twice is the browser's answer.
|
||
//
|
||
// The sentence on the screen cannot carry a reason — it is one line
|
||
// over a strip of photographs — so the reason goes to the console
|
||
// and a word of it into the note, which is the whole of what tells
|
||
// a folder that was moved from a permission that lapsed.
|
||
console.error('backup:', err);
|
||
if (attempt === 0 && needsPick(err) && (await pickBackup())) continue;
|
||
if (!quiet) setNote(t('lib.backupFailed', { why: why(err) }));
|
||
break;
|
||
}
|
||
}
|
||
} finally {
|
||
setBackBusy(false);
|
||
}
|
||
},
|
||
[backBusy, pickBackup, show, t]
|
||
);
|
||
|
||
// Where the copy goes, said out loud: the line that names the folder is the
|
||
// control that changes it. A backup is a folder of rows and tiles, so the
|
||
// folder's name — the one the reader gave it in the picker, or the disk it was
|
||
// carried in on — is the whole of what tells one backup from another, and
|
||
// nothing else on this screen can offer a name.
|
||
const choose = useCallback(async () => {
|
||
if (backBusy) return;
|
||
await pickBackup();
|
||
}, [backBusy, pickBackup]);
|
||
|
||
// The other direction, and the one that replaces rows: the frames come back
|
||
// without a file handle — a handle is not a thing that can be written down —
|
||
// and picking the folder again afterwards is what hands every one of them back,
|
||
// without a frame being read.
|
||
//
|
||
// The folder is asked for every time, whatever this screen remembers, because
|
||
// the folder is the whole of the choice: which backup to come back from is
|
||
// which folder the reader points at — the copy on the other disk, the one made
|
||
// before the edit. The count and the date on the confirm are read out of the
|
||
// folder just picked, so the thing described is the thing restored, and a
|
||
// folder with no catalogue of its own is a folder to point somewhere else at
|
||
// rather than a run that replaces the reading with nothing.
|
||
const restore = useCallback(async () => {
|
||
if (backBusy) return;
|
||
if (!(await pickBackup())) return;
|
||
const status = backRef.current;
|
||
if (!status?.at) {
|
||
setNote(t('lib.restoreNothing', { folder: status?.folder ?? '' }));
|
||
return;
|
||
}
|
||
const when = new Date(status.at).toLocaleString();
|
||
if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder ?? '', n: status.photos, time: when }))) return;
|
||
setBackBusy(true);
|
||
setNote(t('lib.restoreRun', { done: 0, total: status.photos }));
|
||
try {
|
||
const result = await restoreNow((p) => setNote(t('lib.restoreRun', { done: p.done, total: p.total })));
|
||
await reload();
|
||
setFolders(await listFolders());
|
||
setNote(t('lib.restoreDone', { n: result.photos }));
|
||
} catch {
|
||
setNote(t('lib.restoreFailed'));
|
||
} finally {
|
||
setBackBusy(false);
|
||
}
|
||
}, [backBusy, pickBackup, reload, t]);
|
||
|
||
// A reading that is over is the one moment the rows are finished, so it is the
|
||
// moment the catalogue is copied out: a timer would fire in the middle of a
|
||
// roll or not at all. Held in a ref because the watch below subscribes once and
|
||
// this changes with every render — and nothing is written without a folder
|
||
// picked and the browser still allowing the write, which are the two states the
|
||
// row on screen is already saying.
|
||
const afterScan = useRef(() => {});
|
||
afterScan.current = () => {
|
||
const status = backRef.current;
|
||
if (status?.folder && status.granted) void backup(true);
|
||
};
|
||
|
||
// 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, and the frames themselves are handed over by the reading
|
||
// as it stores them — so the strip fills with the roll and the only read back
|
||
// is the one a reading in another window needs, whose rows never come here.
|
||
useEffect(() => {
|
||
// The folder the last pass was on, so the pass that comes back empty — the
|
||
// one that says the scan is through — is recognisable as an end rather than
|
||
// as a screen with nothing to do.
|
||
let was: string | null = null;
|
||
// How much of the roll the catalogue was last read back at, and the shape the
|
||
// column was last drawn at: reading the whole catalogue back costs every
|
||
// thumbnail in it, so it waits for a batch to have landed rather than for a
|
||
// frame to have been read, and the column is redrawn only when a pass has
|
||
// walked into something new.
|
||
let wrote = -1;
|
||
let drew = '';
|
||
// And when it was last read back. A batch lands every second or so on a
|
||
// folder of JPEGs, and reading a catalogue of a hundred thousand frames is a
|
||
// second and a half of the page held still and sixty megabytes of rows thrown
|
||
// away — a scan that read it back every batch spent the roll doing nothing
|
||
// else, and a folder of RAW, with a quarter of a gigabyte of LibRaw open
|
||
// beside it, went past what a page is given and took the tab with it. On a
|
||
// clock the strip still fills while the scan runs.
|
||
let read = 0;
|
||
// And how many frames arrived the short way, handed over by the reading
|
||
// itself: a reading of this window's hands over every frame it stores, and
|
||
// there is no read to do for any of them.
|
||
let fresh = 0;
|
||
const watching = () => {
|
||
bump();
|
||
const session = scanSession();
|
||
const folder = session?.folder ?? null;
|
||
// A reading that is over leaves the catalogue it filled behind: this screen
|
||
// reads back the last of it, whether the reading was this window's or the
|
||
// one beside it — and the window watching never saw it otherwise, because a
|
||
// reading that is through has nothing left to hand over.
|
||
if (was && !folder) {
|
||
was = null;
|
||
void reload();
|
||
afterScan.current();
|
||
return;
|
||
}
|
||
was = folder;
|
||
if (!session) return;
|
||
const { progress } = session;
|
||
const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : '';
|
||
if (shape !== drew) {
|
||
drew = shape;
|
||
// The names of a reading kept to one folder are the folders under that
|
||
// one and no others: the column keeps what it has, and the rows it
|
||
// already drew stay where they are.
|
||
if (!progress.from) setDirs(progress.dirs);
|
||
}
|
||
// A window beside the reading is handed the progress of another build, which
|
||
// may have no such count: the frames it has stored are then read back on a
|
||
// frame, as they were before there was one.
|
||
const written = progress.written ?? progress.done;
|
||
const now = Date.now();
|
||
if (written !== wrote && now - read >= CATALOGUE_MS) {
|
||
wrote = written;
|
||
read = now;
|
||
// Nothing came over the side, so the frames are another window's and
|
||
// there is nothing else to draw them from. The reading in this window
|
||
// handed its own over as they landed.
|
||
if (!fresh) void reload();
|
||
fresh = 0;
|
||
}
|
||
};
|
||
let pendingRows: LibraryPhoto[] = [];
|
||
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const flushPending = () => {
|
||
if (pendingRows.length > 0) {
|
||
const toAdd = pendingRows;
|
||
pendingRows = [];
|
||
setPhotos((prev) => [...prev, ...toAdd]);
|
||
}
|
||
updateTimer = null;
|
||
};
|
||
|
||
watchRows((rows) => {
|
||
fresh += rows.length;
|
||
pendingRows.push(...rows);
|
||
if (!updateTimer) {
|
||
updateTimer = setTimeout(flushPending, 500);
|
||
}
|
||
});
|
||
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 () => {
|
||
if (updateTimer) clearTimeout(updateTimer);
|
||
flushPending();
|
||
unwatch();
|
||
watchRows(null);
|
||
};
|
||
}, [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.
|
||
// 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 () => {
|
||
// 1. Trigger catalog reload immediately in parallel
|
||
const reloadPromise = reload();
|
||
|
||
// 2. Fetch list of directory folders
|
||
const rows = await listFolders();
|
||
if (!alive) return;
|
||
setFolders(rows);
|
||
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
|
||
|
||
// 3. Query directory permissions in parallel
|
||
const permResults = await Promise.all(
|
||
rows.map(async (folder) => ({
|
||
name: folder.name,
|
||
ok: await ensurePermission(folder.handle),
|
||
}))
|
||
);
|
||
if (!alive) return;
|
||
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
|
||
setReady(ok);
|
||
|
||
// 4. Await photos reload if still running
|
||
await reloadPromise;
|
||
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, the width its column was dragged to, and whether the strip drew the whole
|
||
// branch under that folder or only the folder itself.
|
||
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]);
|
||
useEffect(() => {
|
||
keep(DEEP_KEY, String(deep));
|
||
}, [deep]);
|
||
useEffect(() => {
|
||
keep(VIEW_KEY, view);
|
||
}, [view]);
|
||
|
||
// Clear global memory caches when leaving Library screen
|
||
useEffect(() => {
|
||
return () => {
|
||
shots.clear();
|
||
clearLibraryCache();
|
||
};
|
||
}, []);
|
||
|
||
// 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 && !photoMenu) return;
|
||
const away = (e: Event) => {
|
||
if (e.target instanceof Element && e.target.closest('.lib-menu')) return;
|
||
setMenu(null);
|
||
setPhotoMenu(null);
|
||
};
|
||
const key = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
setMenu(null);
|
||
setPhotoMenu(null);
|
||
}
|
||
};
|
||
document.addEventListener('pointerdown', away);
|
||
document.addEventListener('keydown', key);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', away);
|
||
document.removeEventListener('keydown', key);
|
||
};
|
||
}, [menu, photoMenu]);
|
||
|
||
// A reading asked for here is a request, not a reading: one walk runs at a time,
|
||
// and a folder asked for while another is being read waits its turn — which is
|
||
// what makes a right click on four folders mean four readings rather than three
|
||
// refusals. The note lands when this request's turn comes, not when it is asked
|
||
// for, and a request called off before it ran says nothing at all.
|
||
const scan = useCallback(
|
||
async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => {
|
||
setNote(null);
|
||
try {
|
||
const result = await scanAsked(folder, from, mode);
|
||
if (!result) return;
|
||
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, added: result.added, total: result.total }));
|
||
// A reading kept to one folder names the folders under that one, which
|
||
// is no reason to fold the column down to them: the roll is still the
|
||
// roll, and the rows outside the folder being re-read are rows the
|
||
// reader is looking at.
|
||
if (!from) setDirs(result.dirs);
|
||
// No read back here. The reading handed its frames over as it stored
|
||
// them, and the screen watching it reads the catalogue back once the
|
||
// reading is done — the same read, half a beat later, and not a second
|
||
// one of the whole catalogue for a strip that is already drawn.
|
||
} catch {
|
||
setNote(t('lib.failed'));
|
||
}
|
||
},
|
||
[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 — but only when the last reading of it never
|
||
// finished. A reading that ran to its end has nothing left to find, and the
|
||
// reader who opens the library to choose a frame is not a reader who asked for
|
||
// their disk walked behind their back: on the folder they are opening, a walk
|
||
// is a hundred thousand names read off the disk and the band and the memory
|
||
// that goes with them, which is the whole of what this screen must not cost.
|
||
// A folder the reader wants looked at again says so itself, from the menu on
|
||
// its row; what is picked up here is the roll whose reading ended when the app
|
||
// did, and nothing else. No note either way: the ring on the row and the
|
||
// progress line are the report.
|
||
const rootKey = node ? node.split('/')[0] : null;
|
||
// When the folder was last walked for being raised. It stands outside the
|
||
// effect because the effect is rebuilt for the folder the reader moves to, and
|
||
// the walk it guards is of the folder on the disk, where nothing has moved
|
||
// either way.
|
||
const synced = useRef(0);
|
||
useEffect(() => {
|
||
const root = folders.find((f) => f.name === rootKey);
|
||
if (!loaded || !root || !ready.has(root.name)) return;
|
||
// Another window may hold the reading — the app beside the browser, which
|
||
// this window cannot see. Asking is a round trip, and the answer is what
|
||
// keeps this one from reading the same roll a second time.
|
||
const catchUp = () => {
|
||
if (scanSession()) return;
|
||
// A folder of a hundred thousand frames is a hundred thousand stats on the
|
||
// disk, and this screen is raised every time the reader comes back from the
|
||
// studio — so the walk waits for the folder to have had a moment to change,
|
||
// which is a second of a roll the reader was not going to notice and not
|
||
// the several it took to walk it again.
|
||
if (Date.now() - synced.current < CATCHUP_MS) return;
|
||
synced.current = Date.now();
|
||
void scanBusy().then(async (busy) => {
|
||
if (busy || scanSession()) return;
|
||
// A position left on the disk is a roll that stopped in the middle of
|
||
// itself and the only thing that finishes it is reading it again. This
|
||
// is the question, and it is answered by one small file opened and shut
|
||
// — not by the walk it decides on.
|
||
if (await unfinished(root.name)) 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;
|
||
|
||
const relInfo = await findFolderRelationship(folder.handle, folders);
|
||
|
||
if (relInfo.type === 'subfolder') {
|
||
const { parent, relPath } = relInfo;
|
||
const subNodeKey = `${parent.name}/${relPath}`;
|
||
const parts = relPath.split('/');
|
||
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
let curr = parent.name;
|
||
next.add(curr);
|
||
for (let i = 0; i < parts.length; i++) {
|
||
curr = `${curr}/${parts[i]}`;
|
||
next.add(curr);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
setDirs((prev) => {
|
||
if (prev.some((d) => d.id === subNodeKey)) return prev;
|
||
return [...prev, { id: subNodeKey, folder: parent.name, rel: relPath }];
|
||
});
|
||
|
||
setNode(subNodeKey);
|
||
await scan(parent, false, relPath + '/');
|
||
} else if (relInfo.type === 'parent') {
|
||
const { child } = relInfo;
|
||
const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort((a, b) =>
|
||
a.name.localeCompare(b.name)
|
||
);
|
||
setFolders(nextFolders);
|
||
setReady((prev) => new Set(prev).add(folder.name));
|
||
setNode(folder.name);
|
||
await scan(folder);
|
||
} else {
|
||
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) {
|
||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||
setNote(t('lib.failed'));
|
||
}
|
||
}, [folders, scan, t]);
|
||
|
||
const reconnect = useCallback(
|
||
async (folder: LibraryFolder) => {
|
||
if (await ensurePermission(folder.handle)) {
|
||
setReady((prev) => new Set(prev).add(folder.name));
|
||
await reconnectPhotosForFolders(folder.handle);
|
||
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.
|
||
// A reading kept to one folder counts the frames of that folder and of what
|
||
// lies under it, with nothing at all to say about the rest of the roll: its
|
||
// numbers drawn over the column would count a roll down to a branch of itself.
|
||
// The column falls back on the catalogue until the reading is through.
|
||
const read = live && !live.progress.from ? live.progress.counts : null;
|
||
// Memoize photo count map derived from photos and dirs array separately so that
|
||
// live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
|
||
const baseTreeInfo = useMemo(() => {
|
||
const hits = new Map<string, number>();
|
||
const foldersUnder = new Map<string, Set<string>>();
|
||
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);
|
||
}
|
||
for (const dir of dirs) add(dir.folder, dir.rel, false);
|
||
return { hits, foldersUnder };
|
||
}, [photos, dirs]);
|
||
|
||
const nodes = useMemo<Node[]>(() => {
|
||
const { hits, foldersUnder } = baseTreeInfo;
|
||
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
|
||
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;
|
||
}, [baseTreeInfo, folders, 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;
|
||
// The strip draws the tiles around the one under the eye, not the whole shelf.
|
||
// A roll is thousands of frames and every tile drawn costs a `<button>`, an
|
||
// `<img>` and an object URL of its own — and a URL is about a tenth of a
|
||
// millisecond to make, which is a second of blocked main thread for pictures
|
||
// nobody has scrolled to. The spacers on either side of the drawn run keep the
|
||
// scrollbar measuring the shelf.
|
||
const strip = useStripWindow(shown.length, view === 'preview');
|
||
const drawn = useMemo(
|
||
() => shown.slice(Math.min(strip.from, shown.length), Math.min(strip.to, shown.length)),
|
||
[shown, strip.from, strip.to]
|
||
);
|
||
// 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]);
|
||
|
||
// The wall is the other list, and it draws its rows the way the strip draws its
|
||
// tiles: the ones around the eye, with a spacer standing in for the rest. A card
|
||
// is a card, an `<img>` and an object URL of its own, and twenty thousand of them
|
||
// is seven seconds of dead screen — the URL is only a tenth of it; the rest is
|
||
// the DOM and the number the browser formats for each date. The frame on the
|
||
// stage is given a picture wherever it sits — the stage is not the wall.
|
||
const wall = useWallWindow(listed.length, view === 'grid');
|
||
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
|
||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
|
||
|
||
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
||
const treeRef = useRef<HTMLElement>(null);
|
||
const scrolledNodeRef = useRef<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!node || !loaded) return;
|
||
if (scrolledNodeRef.current === node) return;
|
||
|
||
const scrollToNode = () => {
|
||
const treeEl = treeRef.current;
|
||
if (!treeEl) return;
|
||
const activeBtn = treeEl.querySelector<HTMLElement>('.adm-album.on');
|
||
if (activeBtn) {
|
||
activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
scrolledNodeRef.current = node;
|
||
}
|
||
};
|
||
|
||
scrollToNode();
|
||
const timer = setTimeout(scrollToNode, 120);
|
||
return () => clearTimeout(timer);
|
||
}, [node, loaded, rows]);
|
||
|
||
// Auto-scroll timeline filmstrip to center the active selected thumbnail
|
||
const scrolledFrameRef = useRef<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (view !== 'preview' || !frame || !shown.length) return;
|
||
const idx = shown.findIndex((p) => p.id === frame.id);
|
||
if (idx < 0) return;
|
||
|
||
const scrollStripToFrame = () => {
|
||
const el = strip.ref.current;
|
||
if (!el) return;
|
||
const width = el.clientWidth || 800;
|
||
const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2);
|
||
el.scrollLeft = targetLeft;
|
||
};
|
||
|
||
const key = `${node}:${frame.id}`;
|
||
if (scrolledFrameRef.current !== key) {
|
||
scrolledFrameRef.current = key;
|
||
scrollStripToFrame();
|
||
requestAnimationFrame(scrollStripToFrame);
|
||
}
|
||
}, [view, frame?.id, shown, node, strip.ref]);
|
||
|
||
// 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;
|
||
}
|
||
})();
|
||
keepShot(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]);
|
||
|
||
const [stageUrl, setStageUrl] = useState<string | null>(null);
|
||
const stageUrlRef = useRef<string | null>(null);
|
||
|
||
const revokeStageUrl = () => {
|
||
if (stageUrlRef.current) {
|
||
URL.revokeObjectURL(stageUrlRef.current);
|
||
stageUrlRef.current = null;
|
||
}
|
||
};
|
||
|
||
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
|
||
useEffect(() => {
|
||
revokeStageUrl();
|
||
setStageUrl(null);
|
||
if (!frame) return;
|
||
|
||
let live = true;
|
||
void (async () => {
|
||
try {
|
||
const file = await readPhotoFile(frame);
|
||
if (!live) return;
|
||
|
||
let url: string | null = null;
|
||
if (isRawName(file.name)) {
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
if (!live) return;
|
||
const previewBytes = await rawThumbnail(bytes);
|
||
if (previewBytes && live) {
|
||
const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
|
||
url = URL.createObjectURL(blob);
|
||
}
|
||
} else {
|
||
url = URL.createObjectURL(file);
|
||
}
|
||
|
||
if (live && url) {
|
||
revokeStageUrl();
|
||
stageUrlRef.current = url;
|
||
setStageUrl(url);
|
||
} else if (url) {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
} catch {
|
||
// Fall back to thumb tile URL if file reading fails
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
live = false;
|
||
revokeStageUrl();
|
||
};
|
||
}, [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. The word is only for a frame that has no
|
||
// thumbnail to give: one that has one and has not been handed it yet — the wall
|
||
// hands pictures out only around the eye — keeps the box and says nothing.
|
||
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}>
|
||
{photo.thumb ? '' : '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" onClick={() => void add()}>
|
||
{t('lib.add')}
|
||
</button>
|
||
{live || scanQueue().length ? (
|
||
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
||
{t('lib.stop')}
|
||
</button>
|
||
) : null}
|
||
{/* The catalogue's own copy: one button that picks the folder the
|
||
first time and writes into it every time after, and one that
|
||
reads it back. Both are furniture next to the reading itself,
|
||
so they sit behind the hint rather than in front of it. */}
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
data-key="lib-backup"
|
||
disabled={backBusy}
|
||
onClick={() => void backup()}
|
||
>
|
||
{t('lib.backup')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
data-key="lib-restore"
|
||
disabled={backBusy}
|
||
onClick={() => void restore()}
|
||
>
|
||
{t('lib.restore')}
|
||
</button>
|
||
{/* The folder is a control, not a caption: it is the whole of what
|
||
tells one backup from another, and pressing it is how a folder
|
||
is picked, a name given, a permission handed back. */}
|
||
<button
|
||
type="button"
|
||
className="hint"
|
||
data-key="lib-backup-state"
|
||
disabled={backBusy}
|
||
title={t('lib.backupPick')}
|
||
onClick={() => void choose()}
|
||
>
|
||
{back?.folder
|
||
? back.granted
|
||
? t('lib.backupAt', {
|
||
folder: back.folder,
|
||
n: back.photos,
|
||
time: back.at ? new Date(back.at).toLocaleString() : '—',
|
||
})
|
||
: t('lib.backupBlocked', { folder: back.folder })
|
||
: t('lib.backupNone')}
|
||
</button>
|
||
<span className="hint lib-bar-hint" title={t('lib.hint')}>
|
||
{t('lib.hint')}
|
||
</span>
|
||
{/* Nothing is drawn until the reading knows what it is reading: a
|
||
roll of nothing over nothing is the line talking before the
|
||
first pass has come back, which is a scan of a hundred
|
||
thousand frames announced as `0/0`. */}
|
||
<span className="hint" data-key="lib-progress">
|
||
{live && live.progress.total
|
||
? 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>
|
||
|
||
{/* The folders are on their way in for as long as the catalogue behind
|
||
them is being read back, and a screen that says there is no folder
|
||
before it has looked is a screen that says it every visit. */}
|
||
{loaded && 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
|
||
ref={treeRef}
|
||
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 - 150), y: Math.min(e.clientY, window.innerHeight - 150) });
|
||
}}
|
||
>
|
||
{rows.map((n) => {
|
||
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
|
||
// The row that says a reading is under way is the folder the
|
||
// reading stands in — not the folder it was pointed at, which
|
||
// would be the same row for the whole of an afternoon while
|
||
// the work went on somewhere the reader cannot see — together
|
||
// with every folder above it, so a branch folded shut still
|
||
// shows the work going on under it. The roll's own row is
|
||
// never one of them: it is the head of everything below it,
|
||
// and a head that says all of a tree is being read says
|
||
// nothing about the folder the reading is actually in. The
|
||
// frames that sit in the roll's own folder are the one case
|
||
// left bare, and the toolbar's line is what carries them.
|
||
const dir = relOf(n.key);
|
||
const at = live && live.folder === folder?.name && live.progress.at ? `${live.progress.at}/` : null;
|
||
const scanning = dir !== null && dir !== '' && at !== null && (dir === at || at.startsWith(dir));
|
||
// A row whose reading has not started yet: the ring is
|
||
// there, held still, so a reader sees the roll is pending
|
||
// rather than idle.
|
||
const waiting = dir !== null && dir !== '' && !scanning && scanQueue().some((j) => j.folder === folder?.name && j.rel === dir);
|
||
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. Rename
|
||
// and forget act on the folder that was picked, so a right
|
||
// click on a subfolder row brings up its roll's menu. The
|
||
// scan acts on the row under the pointer instead: the
|
||
// reader who right-clicks April wants April read, and
|
||
// reading the four years around it to reach the one folder
|
||
// they are looking at is a roll read for nothing. 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();
|
||
// The row's own path under the picked folder, '' for
|
||
// the picked folder itself — what a scan started from
|
||
// here would be kept to. Spelled the way the walk
|
||
// spells it, trailing slash and all.
|
||
const under = n.key.slice(folder.name.length + 1);
|
||
setMenu({
|
||
folder,
|
||
root: n.depth === 0,
|
||
rel: under ? `${under}/` : '',
|
||
node: n.key,
|
||
x: Math.min(e.clientX || box.left, window.innerWidth - 150),
|
||
y: Math.min(e.clientY || box.top, window.innerHeight - 150),
|
||
});
|
||
}}
|
||
>
|
||
<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" />
|
||
) : waiting ? (
|
||
<span className="lib-spin wait" 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>
|
||
{/* The wall is the shelf's own length, so the scrollbar on
|
||
its right edge still reaches every frame; what is drawn
|
||
is the rows around the eye, with a spacer standing in
|
||
for the rows above and below. A card is a card, an
|
||
<img> and an object URL of its own, and twenty thousand
|
||
of them is a dead screen. */}
|
||
<div className="adm-grid" ref={wall.ref}>
|
||
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||
{painted.map((photo) => (
|
||
<article
|
||
className="adm-card"
|
||
key={photo.id}
|
||
data-key={`lib-photo-${photo.id}`}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setPhotoMenu({
|
||
photo,
|
||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||
});
|
||
}}
|
||
>
|
||
{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>
|
||
))}
|
||
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
|
||
</div>
|
||
</>
|
||
) : frame ? (
|
||
<>
|
||
{stageUrl || urls[frame.id] ? (
|
||
<img
|
||
className="adm-stage-img"
|
||
data-key="lib-stage-img"
|
||
ref={frameRef}
|
||
src={stageUrl || urls[frame.id]!}
|
||
alt=""
|
||
onClick={() => void open(frame)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setPhotoMenu({
|
||
photo: frame,
|
||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||
});
|
||
}}
|
||
title={t('lib.open')}
|
||
style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }}
|
||
/>
|
||
) : (
|
||
<div
|
||
className="lib-noimg"
|
||
onClick={() => void open(frame)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setPhotoMenu({
|
||
photo: frame,
|
||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||
});
|
||
}}
|
||
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) — the ones
|
||
around the eye, with a spacer standing in for each run that is
|
||
not drawn, so the scrollbar still measures the whole shelf. */}
|
||
{view === 'preview' ? (
|
||
<div
|
||
ref={strip.ref}
|
||
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;
|
||
}}
|
||
>
|
||
{strip.from > 0 ? (
|
||
<span className="adm-thumb-gap" style={{ flexBasis: strip.from * TILE_STEP - 8 }} aria-hidden="true" />
|
||
) : null}
|
||
{drawn.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)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setPhotoMenu({
|
||
photo,
|
||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||
});
|
||
}}
|
||
>
|
||
{tile(photo)}
|
||
<span className="adm-thumb-cap">{photo.name}</span>
|
||
</button>
|
||
))}
|
||
{shown.length - strip.to > 0 ? (
|
||
<span className="adm-thumb-gap" style={{ flexBasis: (shown.length - strip.to) * TILE_STEP - 8 }} aria-hidden="true" />
|
||
) : null}
|
||
{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>
|
||
{/* Three things can be asked of a roll: bring it up to date, read it
|
||
from the top again, or leave it — and the last only of the
|
||
reading that is this roll's, or of the request for it that is
|
||
still waiting its turn. The first two are always there, because
|
||
a reading already running is not a reason a reader cannot ask
|
||
for the next one: it queues. */}
|
||
{live?.folder === menu.folder.name || queued ? (
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key={`lib-menu-stop-${menu.folder.name}`}
|
||
onClick={() => {
|
||
setMenu(null);
|
||
stopScanAt(menu.folder!.name, menu.rel ?? '');
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">■</span>
|
||
{t('lib.stop')}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key={`lib-update-${menu.folder.name}`}
|
||
onClick={() => {
|
||
setMenu(null);
|
||
void scan(menu.folder!, false, menu.rel ?? '', 'update');
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">↻</span>
|
||
{t('lib.update')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key={`lib-rescan-${menu.folder.name}`}
|
||
onClick={() => {
|
||
setMenu(null);
|
||
void scan(menu.folder!, false, menu.rel ?? '', 'rescan');
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
|
||
{t('lib.rescan')}
|
||
</button>
|
||
{/* A row with folders under it is a row that folds, and the branch
|
||
it holds goes with it: a reader who is done with a tree means
|
||
the whole of it shut, not the one row they happened to point
|
||
at with its children left open underneath. */}
|
||
{menu.node && parents.has(menu.node) ? (
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key={`lib-fold-${menu.node}`}
|
||
onClick={() => {
|
||
const key = menu.node!;
|
||
setMenu(null);
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
for (const k of next) if (k === key || k.startsWith(`${key}/`)) next.delete(k);
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">▴</span>
|
||
{t('lib.collapse')}
|
||
</button>
|
||
) : null}
|
||
<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"
|
||
onClick={() => {
|
||
setMenu(null);
|
||
void add();
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">+</span>
|
||
{t('lib.add')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{photoMenu ? (
|
||
<div
|
||
className="lib-menu"
|
||
data-key="lib-photo-menu"
|
||
style={{ left: photoMenu.x, top: photoMenu.y }}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key="lib-photo-open-explorer"
|
||
onClick={async () => {
|
||
const photo = photoMenu.photo;
|
||
setPhotoMenu(null);
|
||
const folderPath = photo.dir ? `${photo.folder}/${photo.dir}` : photo.folder;
|
||
const fullPath = `${folderPath}/${photo.name}`;
|
||
try {
|
||
if (navigator.clipboard) {
|
||
await navigator.clipboard.writeText(fullPath);
|
||
setNote(t('lib.copiedPath', { path: fullPath }));
|
||
}
|
||
} catch {}
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">📁</span>
|
||
{t('lib.openExplorer')}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key="lib-photo-open-location"
|
||
onClick={() => {
|
||
const photo = photoMenu.photo;
|
||
setPhotoMenu(null);
|
||
const targetNode = photo.dir ? `${photo.folder}/${photo.dir}` : photo.folder;
|
||
setNode(targetNode);
|
||
setUp(photo.id);
|
||
const parts = targetNode.split('/');
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
for (let i = 1; i <= parts.length; i++) {
|
||
next.add(parts.slice(0, i).join('/'));
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">🎯</span>
|
||
{t('lib.openLocation')}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="lib-menu-item"
|
||
data-key="lib-photo-open-studio"
|
||
onClick={() => {
|
||
const photo = photoMenu.photo;
|
||
setPhotoMenu(null);
|
||
void open(photo);
|
||
}}
|
||
>
|
||
<span className="lib-menu-icon" aria-hidden="true">🎨</span>
|
||
{t('lib.openStudio')}
|
||
</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 the list the panels paint is
|
||
// the only list to make them for. `also` is a frame drawn outside that list — the
|
||
// one on the stage, whose tile may be nowhere near the strip's window.
|
||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
||
const [urls, setUrls] = useState<Record<string, string>>({});
|
||
const made = useRef(new Map<string, { url: string; blob: Blob }>());
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
|
||
if (!list.length) {
|
||
setUrls({});
|
||
return;
|
||
}
|
||
const ids = list.map((p) => p.id);
|
||
|
||
void getPhotoThumbs(ids).then((thumbMap) => {
|
||
if (!alive) return;
|
||
const next: Record<string, string> = {};
|
||
for (const photo of list) {
|
||
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||
if (!blob) continue;
|
||
const had = made.current.get(photo.id);
|
||
if (had && had.blob === blob) {
|
||
next[photo.id] = had.url;
|
||
} else {
|
||
if (had) URL.revokeObjectURL(had.url);
|
||
const url = URL.createObjectURL(blob);
|
||
made.current.set(photo.id, { url, blob });
|
||
next[photo.id] = url;
|
||
}
|
||
}
|
||
for (const [id, item] of made.current) {
|
||
if (id in next) continue;
|
||
URL.revokeObjectURL(item.url);
|
||
made.current.delete(id);
|
||
}
|
||
setUrls(next);
|
||
});
|
||
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [photos, also]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
|
||
made.current.clear();
|
||
},
|
||
[]
|
||
);
|
||
|
||
return urls;
|
||
}
|
||
|
||
// Which tiles of the strip are drawn: the run under the viewport and `STRIP_KEEP`
|
||
// either side of it. Without this the strip builds a tile for every frame the open
|
||
// node holds before the reader has seen one — 400ms of DOM and a second of object
|
||
// URLs on a roll of twenty thousand — and the screen is dead for the whole of it.
|
||
// `on` is the strip being up: the element the window measures is the strip's own.
|
||
function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElement>; from: number; to: number } {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
// The first window is a screenful: the strip is not in the DOM yet when this
|
||
// runs, so what it measures is the next paint's, and this is what the first
|
||
// paint draws.
|
||
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, STRIP_KEEP * 2 + 24) }));
|
||
useEffect(() => {
|
||
if (!on) return;
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const measure = () => {
|
||
const from = Math.max(0, Math.floor(el.scrollLeft / TILE_STEP) - STRIP_KEEP);
|
||
const to = Math.min(count, Math.ceil((el.scrollLeft + el.clientWidth) / TILE_STEP) + STRIP_KEEP);
|
||
setWin((prev) => (prev.from === from && prev.to === to ? prev : { from, to }));
|
||
};
|
||
measure();
|
||
el.addEventListener('scroll', measure, { passive: true });
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
return () => {
|
||
el.removeEventListener('scroll', measure);
|
||
ro.disconnect();
|
||
};
|
||
}, [count, on]);
|
||
return { ref, from: win.from, to: win.to };
|
||
}
|
||
|
||
|
||
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
|
||
// either side, with a spacer standing in for the rest so the scrollbar still spans
|
||
// the shelf. The wall holds every frame the open node does — a filter is a way of
|
||
// looking, not a second shelf — and a card is a card, an `<img>` and an object URL
|
||
// of its own; twenty thousand of them is a dead screen for as long as they take to
|
||
// build. `on` is the wall being up, since the element measured is its own.
|
||
function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElement>; from: number; to: number; above: number; below: number } {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
// The first window is a screenful: the wall has not been measured yet.
|
||
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 }));
|
||
const measureRef = useRef<() => void>(() => {});
|
||
// The window over a scroll container is taken rather than kept, so it is taken
|
||
// after every render: the first pass draws a screenful, the next reads the row's
|
||
// step off it and lays out the spacers, and the pass after that leaves both
|
||
// alone. The listeners are for the two things no render can see coming — a
|
||
// scroll of the wall, and the window itself changing size.
|
||
useEffect(() => {
|
||
if (!on) return;
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const measure = () => {
|
||
const cards = el.querySelectorAll<HTMLElement>(':scope > .adm-card');
|
||
const first = cards[0];
|
||
let cols = 0;
|
||
if (first) while (cols < cards.length && cards[cols].offsetTop === first.offsetTop) cols++;
|
||
// One row, or a wall not yet drawn: open on the first screenful and let the
|
||
// next pass read the step off what it drew.
|
||
if (cards.length <= cols) {
|
||
const to = Math.min(count, 24);
|
||
setWin((prev) => (prev.from === 0 && prev.to === to && !prev.above && !prev.below ? prev : { from: 0, to, above: 0, below: 0 }));
|
||
return;
|
||
}
|
||
// The step from one row to the next, taken as the drawn run's own average.
|
||
// The cards do NOT all stand the same height — a caption that wraps makes
|
||
// its row taller — and `offsetTop` is rounded to whole pixels, so the
|
||
// smallest step would be a pixel short on every one of thousands of rows.
|
||
// The average of the rows in hand is the honest guess at the ones that are
|
||
// not in hand.
|
||
const rowsDrawn = Math.floor(cards.length / cols);
|
||
if (rowsDrawn < 2) return;
|
||
const pitch = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1);
|
||
if (!(pitch > 0)) return;
|
||
const rows = Math.ceil(count / cols);
|
||
const row0 = Math.max(0, Math.floor(el.scrollTop / pitch) - WALL_ROWS);
|
||
const row1 = Math.min(rows, Math.ceil((el.scrollTop + el.clientHeight) / pitch) + WALL_ROWS);
|
||
const from = row0 * cols;
|
||
const to = Math.min(count, row1 * cols);
|
||
// A spacer's own height keeps the gap the grid would have left under the
|
||
// last row it stands in for.
|
||
const above = row0 ? row0 * pitch - WALL_GAP : 0;
|
||
const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0;
|
||
setWin((prev) =>
|
||
prev.from === from && prev.to === to && prev.above === above && prev.below === below ? prev : { from, to, above, below }
|
||
);
|
||
};
|
||
measureRef.current = measure;
|
||
const onScroll = () => measure();
|
||
el.addEventListener('scroll', onScroll, { passive: true });
|
||
const ro = new ResizeObserver(onScroll);
|
||
ro.observe(el);
|
||
return () => {
|
||
measureRef.current = () => {};
|
||
el.removeEventListener('scroll', onScroll);
|
||
ro.disconnect();
|
||
};
|
||
}, [count, on]);
|
||
useLayoutEffect(() => {
|
||
measureRef.current();
|
||
});
|
||
return { ref, from: win.from, to: win.to, above: win.above, below: win.below };
|
||
}
|