Files
RecipesCam/docker/frontend/src/Library.tsx
T

3679 lines
168 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, 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 { refreshAuth, useAuth } from './auth';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import {
breathe,
bySourceThenName,
cachedFolders,
cachedPhotos,
canBrowseFolders,
clearLibraryCache,
ensurePermission,
findFolderRelationship,
getPhoto,
getPhotoThumbs,
HEAD_BYTES,
jumpScan,
listEditedIds,
listFolders,
listWalkedDirs,
makeThumb,
makeThumbs,
normPath,
onLibraryBlocked,
onThumbWritten,
pickFolder,
putFolder,
readPhotoFile,
readPhotos,
reconnectPhotosForFolders,
remoteUrl,
removeFolder,
renameFolder,
resetEdit,
scanAsked,
scanBusy,
scanQueue,
scanSession,
setRotation,
setStar,
stopScan,
stopScanAt,
stopCount,
unfinished,
watchRows,
watchScan,
type LibraryDir,
type LibraryFolder,
type LibraryPhoto,
type ScanMode,
} from './engine/library';
import {
backupNow,
backupStatus,
pickBackupFolder,
restoreNow,
type BackupStatus,
} from './engine/libraryBackup';
import {
addImmichFolder,
forgetImmichServer,
immichConfig,
saveImmichAlbums,
searchImmichFolder,
syncImmich,
type ImmichServer,
} from './engine/immich';
import { ImmichDialog } from './ui/ImmichDialog';
// 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';
// The name moved with the default: subfolders used to be drawn unless the reader
// shut them out, and the chip wrote that first answer down on the very first
// visit — so the old key holds 'true' for every reader who never touched it, and
// reading it would keep them on a default they never chose.
const DEEP_KEY = 'recipescam.library.deep2';
const VIEW_KEY = 'recipescam.library.view';
const SORT_BY_KEY = 'recipescam.library.sortBy';
const SORT_DIR_KEY = 'recipescam.library.sortDir';
const MIN_STAR_KEY = 'recipescam.library.minStar';
const YEAR_KEY = 'recipescam.library.year';
const FROM_KEY = 'recipescam.library.from';
const TO_KEY = 'recipescam.library.to';
// The frame the studio is holding (written on its side, see App.tsx): the shelf
// comes back up on that frame rather than on wherever the tree was left.
const LAST_KEY = 'recipescam.library.last';
function TimeRangeSelectInput({
from,
to,
onChange,
t,
}: {
from: string;
to: string;
onChange: (from: string, to: string) => void;
t: (key: any, params?: any) => string;
}) {
const isSet = from !== 'any' || to !== 'any';
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '3px', flexShrink: 0 }}>
<select
className="adm-input"
data-key="lib-filter-from"
value={from}
onChange={(e) => onChange(e.target.value, to)}
style={{ flexShrink: 0, padding: '4px 6px', fontSize: '12px' }}
title={t('lib.hourFrom')}
>
<option value="any">{t('lib.hourFromAny')}</option>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={String(i)}>
{`${String(i).padStart(2, '0')}:00`}
</option>
))}
</select>
<span style={{ fontSize: '12px', opacity: 0.6, userSelect: 'none' }}>-</span>
<select
className="adm-input"
data-key="lib-filter-to"
value={to}
onChange={(e) => onChange(from, e.target.value)}
style={{ flexShrink: 0, padding: '4px 6px', fontSize: '12px' }}
title={t('lib.hourTo')}
>
<option value="any">{t('lib.hourToAny')}</option>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={String(i)}>
{`${String(i).padStart(2, '0')}:00`}
</option>
))}
</select>
{isSet ? (
<button
type="button"
onClick={() => onChange('any', 'any')}
style={{
border: 'none',
background: 'transparent',
color: 'currentColor',
cursor: 'pointer',
fontSize: '12px',
opacity: 0.7,
padding: '2px 4px',
lineHeight: 1,
}}
title="Clear time filter"
>
✕
</button>
) : null}
</div>
);
}
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;
// How many tiles the wall reads and decodes at once when it is the one asking.
// The wall's own frames come first and they come visibly: two at a time, the
// thread handed back between them (`breathe`), is a shelf that fills in front of
// the reader — a screenful of frames read end to end is a dead screen.
const WALL_LANES = 2;
// A folder is walked into with a screenful in sight, not the whole roll: the tiles
// asked for when a folder opens are the ones about to be drawn, and the wall mints
// the rest as it scrolls. A job over every frame of the roll is a decode of every
// frame of the roll — half an hour of disk for a folder nobody has scrolled past
// the first screen of.
// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main
// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one
// folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at. The wall mints the rest as it scrolls, which is
// who the queue was for.
const THUMB_AHEAD = 60;
// 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;
// How many windows the wall may write in a row before it is talking to itself.
// A window read off the window before it is a loop, and React answers the fifty
// turns of one with #185 and a blank screen; four is more than an honest change
// needs — a re-column takes two passes — and the count is handed back by the
// first scroll, resize or change of shelf the reader makes.
const WALL_CARRIES = 4;
// 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 = 30000;
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);
};
// Whether two handles are the same folder on the disk. It is what tells a reader
// picking the folder they already have — a permission to hand back, a roll to
// read again — from a second folder that happens to be called the same thing.
// The API is Chromium's own; a browser without it is a browser where every
// same-named folder is taken for another one, which is the older behaviour.
async function sameDirectory(
a: FileSystemDirectoryHandle,
b: FileSystemDirectoryHandle | undefined
): Promise<boolean> {
if (!b || typeof a.isSameEntry !== 'function') return false;
try {
return await a.isSameEntry(b);
} catch {
return false;
}
}
// The catalogue is for people with an account. The shelves hold nothing the app
// did not read off the visitor's own disk, but browsing them is still the one
// screen that hands a folder over to be walked, and a guest one file picker away
// from a folder that would be read for nothing is not the offer. The gate is
// here rather than in the router so the scan never starts for a guest — the
// catalogue component below is not mounted at all until the account is known.
//
// What the first paint reads is the answer the last visit got, the way the studio
// reads its own session back (see `loadState`): `/me` is a round trip, and a
// screen that draws nothing until it lands draws nothing for as long as the API
// is away — the frames the reader was working through are already on this disk
// and there is no reason to hold them behind it. The record only ever holds this
// visitor's own last answer, and an answer the API then contradicts puts the note
// back on screen the moment it arrives.
// The library is the accounts' shelf, and this screen is the gate on it. What the
// gate reads is the account the whole page shares (see auth.ts): a visitor who
// signed in at the studio is signed in here too, without a second round trip and
// without the panel below asking again for a login they have already given.
//
// `known` is "the API has answered". Until it has, the screen has nothing to
// draw: a session still in flight is not a guest, and opening the shelves early
// or turning the visitor away early would both be guesses.
export function Library() {
const { t } = useI18n();
const { user, known } = useAuth();
// A visit that lands here first — a bookmark, a reload — starts on an empty
// record, so this screen asks as it mounts. It is one `/me` however many screens
// ask: the studio asks for its own tier, and both read the same answer.
useEffect(() => {
void refreshAuth();
}, []);
// Signed out: not this screen, and this screen is mounted the whole time the
// tab is open, so the visitor is not always standing on it — the shelf is a
// panel that shows and hides (see Shell). The turn home is for the address they
// actually opened: a guest on `/library` goes back to the landing page, in place
// of the page they came in on, so a Back press does not walk them into the same
// wall — while a guest on the studio stays on the studio, with the shelf dark.
// The record settles first: an answer that never came is not a sign-out, and the
// catalogue reads this disk rather than the server.
const away = known && !user;
useEffect(() => {
if (away && window.location.pathname.startsWith('/library')) go('/', true);
}, [away]);
if (away) return null;
if (!known) return <p className="hint adm-note">{t('auth.busy')}</p>;
return <Catalogue admin={Boolean(user?.admin)} />;
}
function Catalogue({ admin }: { admin: boolean }) {
const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
const [ready, setReady] = useState<Set<string>>(new Set());
// The Immich servers this account has saved, and the two dialogs that write
// them: one adding a server, one changing which albums an existing node keeps.
// A server is not a folder on the disk — see `engine/immich.ts` — so it is the
// column's second source and the page holds no key of its own.
const [servers, setServers] = useState<ImmichServer[]>([]);
const [addImmich, setAddImmich] = useState(false);
const [editAlbums, setEditAlbums] = useState<LibraryFolder | null>(null);
// 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 a reading has walked into, kept with the catalogue: a roll's own
// shape is on screen the moment the catalogue is, and a reading that was cut
// off leaves the names of what it found behind it. `walked` is what those names
// counted — the frames under each, off the disk as the walk last saw it, which
// is the one number that can go down.
const [dirs, setDirs] = useState<LibraryDir[]>([]);
const [walked, setWalked] = useState<Record<string, number>>({});
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. The node the reader picked is what the screen shows: the frames
// in the folders under it are asked for, not assumed. A roll whose top level
// holds date folders then reads as those folders, and the frames behind them
// arrive when the reader says so. The answer is picked up where the last visit
// left it, the same as the open node above.
const [deep, setDeep] = useState(() => recall(DEEP_KEY) === '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'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
// This screen is mounted for the life of the tab and only shown and hidden
// (see Shell in main.tsx), so a visit that comes back from the studio is not a
// mount: the address is what says the shelf is the screen up again.
const [here, setHere] = useState(() => window.location.pathname.startsWith('/library'));
// 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 [sortBy, setSortBy] = useState<'taken' | 'mtime' | 'type' | 'name' | 'star'>(
() => (recall(SORT_BY_KEY) as any) || 'taken'
);
const [sortDir, setSortDir] = useState<'desc' | 'asc'>(
() => (recall(SORT_DIR_KEY) as any) || 'desc'
);
const [minStar, setMinStar] = useState(() => Number(recall(MIN_STAR_KEY)) || 0);
const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all');
const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any');
const [to, setTo] = useState(() => recall(TO_KEY) || 'any');
// What the shelf's search box holds while it is being typed in. It is not a
// filter: a filter looks through the frames already here, and the search asks
// Immich for frames that are not here at all. Nothing is kept between visits —
// the search node it makes outlives the box.
const [search, setSearch] = useState('');
useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]);
useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]);
useEffect(() => { keep(MIN_STAR_KEY, String(minStar)); }, [minStar]);
useEffect(() => { keep(YEAR_KEY, year); }, [year]);
useEffect(() => { keep(FROM_KEY, from); }, [from]);
useEffect(() => { keep(TO_KEY, to); }, [to]);
// How far the frame is magnified, and the pan offset relative to viewport center.
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
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);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedId, setLastSelectedId] = useState<string | 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);
// Whether another tab still holds the library database at the version it was
// opened with — a tab left open across a build. Nothing can be read until it
// goes, and the screen would otherwise sit blank with no reason on it.
const [libraryBlocked, setLibraryBlocked] = useState(false);
useEffect(() => {
onLibraryBlocked(setLibraryBlocked);
return () => onLibraryBlocked(null);
}, []);
// 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, walkedRows] = await Promise.all([readPhotos(), listEditedIds(), listWalkedDirs()]);
// 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);
// What the readings have walked into, for the column. Lower-cased on the way
// in because that is how a row looks a number up, and the record holds both
// spellings of every key.
const counts: Record<string, number> = {};
for (const row of walkedRows) {
for (const [key, n] of Object.entries(row.counts)) {
// A nought from a reading that did not meet the whole roll is a folder it
// listed and never opened, not a folder it found empty, and taken in it
// draws an empty row over a roll full of frames. Only a reading that says
// it walked the whole of it gets to say nought; the rest of the rows fall
// back on the catalogue. See `WalkedDirs.full`.
if (n === 0 && row.full !== true) continue;
counts[key.toLowerCase()] = n;
}
}
setWalked(counts);
// Folded into whatever the screen already has rather than put in its place: a
// folder the reader picked inside a roll is on the column from the click, and
// a reading in flight has named folders the record has not been written with
// yet. Both are rows, and neither is one to take away.
setDirs((prev) => {
const map = new Map(prev.map((d) => [d.id, d]));
let changed = false;
for (const row of walkedRows) {
for (const d of row.dirs) {
if (!map.has(d.id)) {
map.set(d.id, d);
changed = true;
}
}
}
return changed ? [...map.values()] : prev;
});
}, []);
// --- 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;
// Whether the reading that has just left was stopped rather than finished.
// The copy out is made for a reading that ran to its end; one that was called
// off gets nothing but the read back.
let stopped = false;
// The stop count the reading started at. A reading hands over its folder and
// its numbers and nothing about why it ended — a stop is not in the record it
// leaves — so the screen works it out on the count the engine keeps of every
// stop, this window's or the one beside's.
let stopMark = 0;
// 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) {
if (stopCount() !== stopMark) stopped = true;
was = null;
void reload();
// A reading the reader stopped is a reading that did not finish: the copy
// out reads the whole catalogue and every tile in it, and the peak that
// makes is the thing the STOP press was getting away from.
if (!stopped) afterScan.current();
stopped = false;
return;
}
// Where a reading's start is counted from: the first poll that sees a
// folder there is the first poll after the reading began.
if (!was && folder) stopMark = stopCount();
was = folder;
if (!session) return;
const { progress } = session;
// A window beside the reading is handed the progress of another build, and
// a build whose progress carries no folder list at all is a build whose
// reading this is not — reading a length off it is a throw, and a throw in
// here leaves the scan unwatched in this window for the rest of its life.
const dirs = progress.dirs ?? [];
const shape = dirs.length ? `${dirs.length}:${dirs[dirs.length - 1].id}` : '';
if (shape !== drew) {
drew = shape;
if (dirs.length) {
setDirs((prev) => {
const map = new Map(prev.map((d) => [normPath(d.id).toLowerCase(), d]));
let changed = false;
for (const d of dirs) {
const k = normPath(d.id).toLowerCase();
if (!map.has(k)) {
map.set(k, d);
changed = true;
}
}
return changed ? [...map.values()] : prev;
});
}
}
// 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 = [];
// A frame handed over the side is a frame the read-back that landed beside
// it already carries: a reading this window holds ends, the screen reads
// the catalogue back, and the rows it was handed are still waiting out
// their half second — so the same frame arrives twice and the header
// counts it twice. The id is the frame, so one of it in the strip is
// enough.
setPhotos((prev) => {
const have = new Set(prev.map((p) => p.id));
const landed = toAdd.filter((p) => !have.has(p.id));
return landed.length ? [...prev, ...landed] : prev;
});
}
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. A folder that is really an
// Immich server has no handle to hand back — its key lives on the backend —
// so it is ready by definition, and the column draws it beside the drives.
const permResults = await Promise.all(
rows.map(async (folder) => ({
name: folder.name,
ok: folder.source === 'immich' ? true : folder.handle ? await ensurePermission(folder.handle) : false,
}))
);
if (!alive) return;
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
setReady(ok);
// 4. The saved Immich servers, which is what the column needs to know
// whether the second source has anything in it at all.
const config = await immichConfig();
if (!alive) return;
setServers(config.servers);
// 5. 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]);
// Keep global memory caches in RAM during SPA tab navigation between STUDIO and LIBRARY
// 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]);
// Read a server's node into the catalogue. This is to a server what a reading is
// to a roll: the rows and the column first, the tiles after, and it is
// `engine/immich.ts` that does the work. The node's album rows are put in the
// place of its old ones — an album the reader unticked is a row that has to go,
// and `reload` only ever adds.
const syncServer = useCallback(
async (folder: LibraryFolder) => {
setNote(null);
try {
const res = await syncImmich(folder);
setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]);
setNote(t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name }));
await reload();
} catch {
setNote(t('lib.immich.failed'));
}
},
[reload, t]
);
// 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') => {
// A server's node is not walked: its frames are read by `syncServer`, over
// the backend's own routes. Nothing here may hand a handle-less folder to a
// walk that would take it for a folder of the disk.
if (folder.source === 'immich') return;
// A quiet reading is one nobody asked to hear about — the repair's own,
// whose note says the whole of what it is doing — so it leaves the note
// where it found it. A loud one clears it: its word is the one that lands.
if (!quiet) setNote(null);
try {
const result = await scanAsked(folder, from, mode);
if (!result) return;
// The name the reader gave a folder wins over the one the browser had
// for it: a drive root's own name is `\`, which is no name at all. See
// the label the ADD passes to `putFolder`.
const named = folder.label || folder.name;
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${named}/${from.slice(0, -1)}` : named, 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);
// The repair reads every roll in the catalogue one after another, and a
// read of the whole catalogue between two rolls — plus the copy out
// `afterScan` makes after each one — is the repair's own way of running
// a page out of memory. It reads back once, at its end.
if (!quiet) await reload();
} catch {
setNote(t('lib.failed'));
}
},
[reload, t]
);
// The repair: every roll in the catalogue read back to the disk, one after the
// other, from the top. A reading is the only thing that knows what a folder
// holds now — it drops the rows the disk has nothing for, with their tiles and
// their marks, and draws every number off its own walk — so the repair of a
// catalogue that has drifted from the disk is exactly a rescan of everything in
// it. Frames that have not moved are skipped on their size and their time, so
// the cost is the disk's own listing and not a develop a frame.
const repair = useCallback(async () => {
// The repair is of the disk: a repair reads every roll back to the disk, and a
// server has no disk here to be read back to. Its own word is `ĐỌC LẠI` on
// its row.
const rolls = folders.filter((f) => !f.source);
if (!rolls.length) return;
if (!window.confirm(t('lib.repairConfirm', { n: rolls.length }))) return;
setNote(t('lib.repairRun', { n: rolls.length }));
// The stop button stops the afternoon, repair included: the count is read
// before each roll and a press of STOP anywhere in between — this window's or
// the window beside's, which is told over the same channel — ends the loop
// instead of moving on to the next roll.
const mark = stopCount();
let done = 0;
let stopped = false;
for (const folder of rolls) {
if (stopCount() !== mark) {
stopped = true;
break;
}
// One roll's failure is one roll's: a handle the browser will not hand
// back, a drive that is not there — the rest of the repair still runs.
try {
await scan(folder, true, '', 'rescan');
done++;
} catch {
// The roll keeps what the last reading that could open it left.
}
}
await reload();
setNote(stopped ? t('lib.repairHalt', { n: done }) : t('lib.repairDone', { n: done }));
}, [folders, reload, scan, t]);
// 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: a walk of the folder they are opening 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. So nothing walks
// on the way in. What this screen does instead is go looking — on the way in,
// and again every time the tab is raised — for the one roll whose reading ended
// when the app did, and offer it: the chip by the progress line names the folder
// and one press reads it on from where it stopped. A reading the reader wants of
// a folder whose last one ran to its end is still the menu on that folder's row;
// what the chip picks up is only the roll that was cut off mid-walk.
const rootKey = node ? node.split('/')[0] : null;
// The roll the screen found unfinished, named on the chip beside the progress
// line. It is what stands where the walk it used to run by itself used to be.
const [resume, setResume] = useState<string | null>(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(() => {
if (!loaded) 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;
if (Date.now() - synced.current < CATCHUP_MS) return;
synced.current = Date.now();
void scanBusy().then(async (busy) => {
// A roll already offered is a roll already looked for: asking the disk
// again every time the tab is raised is the walk this screen just gave up.
if (busy || scanSession() || resume) return;
for (const folder of folders) {
// A server has no reading to be cut off: what it was left at is one
// round trip away, and there is no walk here for this chip to offer.
if (folder.source) continue;
if (!ready.has(folder.name)) continue;
if (await unfinished(folder.name)) {
setResume(folder.name);
break;
}
}
});
};
catchUp();
const raised = () => {
if (document.visibilityState === 'visible') catchUp();
};
document.addEventListener('visibilitychange', raised);
return () => document.removeEventListener('visibilitychange', raised);
}, [loaded, folders, ready, scan, resume]);
const add = useCallback(async () => {
setNote(null);
try {
const picked = await pickFolder();
if (!picked?.handle) return;
const handle = picked.handle;
// A folder is named, and everything downstream is named after it: the
// record in the store, the frame ids, the file the walk's position is kept
// in, and the number the column draws. Two folders with one name are one
// folder to all of it — and that is exactly what two drives on Windows are,
// because the browser hands every drive root the same name. So a folder the
// browser could not name is asked for one, and a folder whose name is
// already another folder's is refused rather than folded in with it.
const norm = normPath(picked.name);
const clash = folders.find((f) => normPath(f.name) === norm);
const mine = clash ? await sameDirectory(handle, clash.handle) : false;
if (clash && !mine) {
setNote(t('lib.dupFolder', { folder: clash.label || clash.name || picked.name }));
return;
}
let folder = picked;
if (!norm || (clash && !clash.label)) {
const asked = window.prompt(t('lib.renameAsk'), clash?.label ?? '')?.trim();
if (asked) {
folder = { ...picked, label: asked };
await putFolder(folder);
}
}
const relInfo = await findFolderRelationship(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(bySourceThenName);
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(bySourceThenName)
);
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) => {
// Only a folder of the disk has a permission to be handed back: a server's
// key lives on the backend, and this row never offers this action.
const handle = folder.handle;
if (!handle) return;
if (await ensurePermission(handle)) {
setReady((prev) => new Set(prev).add(folder.name));
await reconnectPhotosForFolders(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]
);
// A server that has just been saved, or one whose albums have been reticked. A
// new server becomes a folder on the column the moment it is saved: nothing
// walks it, so there is nothing to wait for. An existing node keeps its name and
// its frames, which the sync below reads again for the albums it now keeps.
const serverSaved = useCallback(
async (saved: ImmichServer, isNew: boolean) => {
setAddImmich(false);
const edited = editAlbums;
setEditAlbums(null);
// The saved list is read back rather than patched: the backend is what
// holds the key, and what it says is what the dialog will show next time.
const config = await immichConfig(true);
setServers(config.servers);
if (!isNew && edited) {
const next: LibraryFolder = { ...edited, albums: saved.albums };
await putFolder(next);
setFolders((prev) => prev.map((f) => (f.name === next.name ? next : f)));
await syncServer(next);
return;
}
const current = await listFolders();
const folder = await addImmichFolder(saved, current.map((f) => f.name));
setFolders([...current, folder].sort(bySourceThenName));
setReady((prev) => new Set(prev).add(folder.name));
setNode(folder.name);
await syncServer(folder);
},
[editAlbums, syncServer]
);
// The shelf's search box, run. What is typed is a name or a tag, and Immich is
// the one that reads it: a disk's folders are already on the column, and a roll
// is read by walking it rather than by calling it names. The server asked is the
// one the open node belongs to, or the first the account saved. The answer
// becomes a node of its own, because a search is a shelf the reader may come
// back to — `engine/immich.ts` files the frames under the node.
const findImmich = useCallback(
async (query: string) => {
const q = query.trim();
if (!q) return;
const open = folders.find((f) => f.name === node)?.server;
const server = servers.find((s) => s.id === open) ?? servers[0] ?? null;
if (!server) {
setNote(t('lib.search.noServer'));
return;
}
const current = await listFolders();
const folder = await searchImmichFolder(server, q, current);
setFolders([...current.filter((f) => f.name !== folder.name), folder].sort(bySourceThenName));
setReady((prev) => new Set(prev).add(folder.name));
setNode(folder.name);
await syncServer(folder);
},
[folders, node, servers, syncServer, t]
);
// Ngắt kết nối: the key goes and the node goes with it — the address and the
// key are the account's, and a node with neither to read by is a row that could
// only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and
// the server is left saved, so adding it back is the same address and no key at
// all.
const disconnect = useCallback(
async (folder: LibraryFolder) => {
if (folder.server) await forgetImmichServer(folder.server);
await drop(folder);
setServers((await immichConfig(true)).servers);
},
[drop]
);
// 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 (err) {
const off = err instanceof Error && err.message === 'no-folder';
setNote(t(off ? 'lib.offline' : 'lib.missing', { name: row.name }));
return;
}
go(`/app?lib=${encodeURIComponent(photo.id)}`);
}, [t]);
// Put a frame back: the recipe goes, the graded tile goes with it, and the frame
// is drawn again from the negative. Everything this screen holds a copy of goes
// too — the URL it is painting and the mark that says the frame was ever touched
// — which is what makes the wall draw the tile it is about to be handed.
const resetSetting = useCallback(async (photo: LibraryPhoto) => {
const blob = await resetEdit(photo).catch(() => null);
dropThumbUrl(photo.id);
if (blob) cacheObjectUrl(photo.id, blob);
setEdited((prev) => {
if (!prev.has(photo.id)) return prev;
const next = new Set(prev);
next.delete(photo.id);
return next;
});
setTilesAt((n) => n + 1);
}, []);
const rootFolder = useMemo(() => {
if (!node) return folders[0] ?? null;
const normNode = normPath(node);
return (
folders.find((f) => {
const normF = normPath(f.name);
return normNode === normF || normNode.startsWith(`${normF}/`);
}) ?? folders[0] ?? null
);
}, [folders, node]);
const rootName = rootFolder ? rootFolder.name : 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 = useCallback(
(key: string): string | null => {
if (!rootName) return null;
const normRoot = normPath(rootName);
const normKey = normPath(key);
if (normKey === normRoot) return '';
if (normKey.startsWith(`${normRoot}/`)) return `${normKey.slice(normRoot.length + 1)}/`;
return null;
},
[rootName]
);
// MAKING THUMBS: the tiles a folder's frames are short of, made from the frames
// themselves. One job at a time — the target the reader is on — and a job the
// reader has moved away from gives up at its next frame: a tile is a decode, and
// two jobs over the same frames read the same disk twice for nothing.
const thumbJob = useRef(0);
const thumbTarget = useRef<string | null>(null);
const makeThumbsFor = useCallback(
async (
folder: LibraryFolder,
rel: string,
opts: { below?: boolean; loud?: boolean; rows?: LibraryPhoto[] } = {}
) => {
const key = `${normPath(folder.name)}|${normPath(rel)}|${opts.below === false ? 0 : 1}`;
// A quiet ask for the target already being worked is the work in hand: the
// catalogue lands a batch at a time, and every batch is not a reason to drop
// the frames the job has queued and read them again.
if (!opts.loud && thumbTarget.current === key) return 0;
const job = ++thumbJob.current;
thumbTarget.current = key;
try {
const made = await makeThumbs(opts.rows ?? folder, rel, {
below: opts.below,
// The whole of a folder is the reader asking for it by hand; a folder
// opening under the pointer is a screenful, and the wall makes the rest
// as it scrolls.
limit: opts.loud ? undefined : THUMB_AHEAD,
shouldStop: () => thumbJob.current !== job,
});
if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name }));
return made;
} finally {
// The target is let go by the job that stood for it: one cut off in the
// middle is not the one that says the target has been looked at.
if (thumbJob.current === job) thumbTarget.current = null;
}
},
[t]
);
// Selecting a folder asks for its frames' tiles: whatever the catalogue is short
// of is made in the background, so the wall the reader scrolls to is painted
// rather than decoded a frame at a time. The open node's own frames — and the
// branch under it when the screen is drawing the branch — which is the list the
// wall would scroll and no more. Silent: the tiles filling in are the word. The
// count is a dependency because a reading lands a folder's frames a batch at a
// time, and a frame that arrives after the tiles were asked for is a frame with
// none.
useEffect(() => {
if (!loaded || !node || !rootFolder) return;
const rel = relOf(node);
if (rel === null) return;
// The rows come from the screen, which is holding the whole catalogue already:
// reading one folder's worth back out of the store is a walk of the disk for
// rows that are in hand. One folder's worth of it — the open one — so a roll
// picked alongside it is not asked for as well.
// ponytail: keyed on the count, not the array, so a star or a turn landing does
// not set the job off again; the rows of that same render are what goes.
const normRoot = normPath(rootFolder.name);
const rows = photos.filter((p) => normPath(p.folder ?? '') === normRoot);
void makeThumbsFor(rootFolder, rel, { below: deep, rows });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
// 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.counts : null;
const liveDirs = live ? live.progress.dirs : 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) => {
// `normPath`, not a bare backslash swap: a picked drive root is named `\`,
// and `normPath` folds that to the empty root the walk files its frames and
// writes its counts under. Swapped only, the root would spell `/` here and
// `//sub` on the row keys below — two spellings of a folder the walk has
// never heard of, and a number every row would read as nought.
const normF = normPath(folder);
const normRel = normPath(rel);
const lowerF = normF.toLowerCase();
const set = foldersUnder.get(lowerF) ?? new Set<string>();
foldersUnder.set(lowerF, set);
let path = '';
for (const part of normRel.split('/')) {
if (!part) continue;
path = path ? `${path}/${part}` : part;
set.add(path);
if (counted) {
const key = `${lowerF}/${path.toLowerCase()}`;
hits.set(key, (hits.get(key) ?? 0) + 1);
}
}
};
for (const photo of photos) {
const normF = (photo.folder || '').replace(/\\/g, '/');
const normDir = (photo.dir || '').replace(/\\/g, '/');
hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1);
if (normDir) add(normF, normDir, true);
}
for (const dir of dirs) add(dir.folder, dir.rel, false);
if (liveDirs) {
for (const dir of liveDirs) add(dir.folder, dir.rel, false);
}
return { hits, foldersUnder };
}, [photos, dirs, liveDirs]);
const nodes = useMemo<Node[]>(() => {
const { hits, foldersUnder } = baseTreeInfo;
// What a row says: the reading in flight, then what the reading that walked
// this roll found, then the catalogue. The reading first because it is the
// newest thing there is; the walk second because the catalogue is not a
// record of the disk — a frame taken off it is a row nothing takes out — and a
// number read off it is one that can only ever climb; the catalogue last,
// which is all a roll that has never been walked has.
const counted = (key: string) => {
const normKey = normPath(key);
const lowerKey = normKey.toLowerCase();
const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key];
if (readVal !== undefined) return readVal;
const walkVal = walked[lowerKey];
if (walkVal !== undefined) return walkVal;
return hits.get(lowerKey) ?? 0;
};
const out: Node[] = [];
for (const folder of folders) {
const normF = normPath(folder.name);
const lowerF = normF.toLowerCase();
out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF) });
// 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(lowerF) ?? [])].sort((a, b) => a.localeCompare(b))) {
const parts = dir.split('/');
const key = `${normF}/${dir}`;
out.push({
key,
label: parts[parts.length - 1],
depth: parts.length,
count: counted(key),
});
}
}
return out;
}, [baseTreeInfo, folders, read, walked]);
// 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]
);
// Where a mark goes on the column: the row for the folder the reading is
// standing in, and every row above it up to and including the roll's own row.
// Reading D:/A/B1/C2 shows the work on C2 and on B1, on A and on D: — the
// whole path down to where the walk is. As the walk moves on, so does the
// mark: on A the chain is D: alone, in C2 it is D:/A/B1/C2, and a fold shut
// over a branch takes the rows below it out of the column without taking the
// reading off the screen, because the rows above are still there to say it.
// A reading that has not reached a frame yet stands nowhere, and stands on
// the folder it was asked of — from the click, not from the first frame, so a
// command is never a command the reader cannot tell from one that never ran.
// The rows are found by the key the walk would give them — `folder/sub` —
// rather than by taking the row's own name apart, which is the same path
// spelled by two pieces of code and has to agree with the reading's own
// spelling, not merely nearly.
interface Mark {
root: string;
own: string;
}
const markOf = (folderName: string, rel: string, at: string): Mark => {
const root = normPath(folderName);
const cleanRel = (rel || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/$/, '');
const cleanAt = (at || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/$/, '');
const here = normPath(cleanAt || cleanRel);
return { root, own: here ? `${root}/${here}` : root };
};
const liveMark = live ? markOf(live.folder, live.progress.from, live.progress.at) : null;
// A request waiting its turn is not being read and has no folder to stand in,
// so it marks the folder it was asked of.
const askedMarks = scanQueue().map((j) => markOf(j.folder, j.rel, ''));
const wearsMark = (n: Node, m: Mark): boolean => {
const nKey = normPath(n.key).toLowerCase();
const mRoot = normPath(m.root).toLowerCase();
const mOwn = normPath(m.own).toLowerCase();
// Another roll's rows are neither the reading's nor the request's news.
if (nKey !== mRoot && !nKey.startsWith(`${mRoot}/`)) return false;
// The active scanning row itself, and every parent/ancestor row up to root.
return nKey === mOwn || mOwn.startsWith(`${nKey}/`);
};
// Auto-expand parent folders of the active scan path so deep subfolders (levels 4, 5, 6, 7+)
// are visible in the tree while being scanned.
useEffect(() => {
if (!liveMark) return;
const parts = normPath(liveMark.own).split('/');
if (parts.length <= 1) return;
const toExpand: string[] = [];
for (let i = 1; i < parts.length; i++) {
toExpand.push(parts.slice(0, i).join('/'));
}
setExpanded((prev) => {
const missing = toExpand.filter((k) => !prev.has(k));
if (!missing.length) return prev;
const next = new Set(prev);
for (const k of missing) next.add(k);
return next;
});
}, [liveMark?.own]);
// 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 === rootName || n.key === normPath(rootName ?? ''))?.key ?? nodes[0]?.key ?? null);
}, [loaded, node, nodes, rootName]);
// What the open node draws: its own frames, and — when the reader has asked
// for the branch — the ones in every subfolder under it.
const shown = useMemo(() => {
if (!rootName) return photos;
const normRoot = normPath(rootName);
const normNode = normPath(node ?? '');
const dir = normNode === normRoot ? '' : normNode.slice(normRoot.length + 1);
const below = dir === ''
? (p: LibraryPhoto) => normPath(p.dir ?? '') !== ''
: (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`);
const own: LibraryPhoto[] = [];
const both: LibraryPhoto[] = [];
for (const p of photos) {
if (normPath(p.folder ?? '') !== normRoot) continue;
const pDir = normPath(p.dir ?? '');
const isOwn = pDir === dir;
if (isOwn) own.push(p);
if (isOwn || below(p)) both.push(p);
}
// A row counts what sits at or under its folder, so a folder whose frames all
// lie one level down would open on an empty screen under a number that says
// otherwise. The branch is drawn then, and only then: a folder holding frames
// of its own opens on those, and the toggle keeps the last word.
return deep || !own.length ? both : own;
}, [deep, node, photos, rootName]);
// 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' && to !== 'any') {
const f = Number(from);
const tNum = Number(to);
const h = at.getHours();
if (f <= tNum) {
if (h < f || h > tNum) return false;
} else {
if (h < f && h > tNum) return false;
}
} else {
if (from !== 'any' && at.getHours() < Number(from)) return false;
if (to !== 'any' && at.getHours() > Number(to)) return false;
}
return true;
});
const getExt = (filename: string) => {
const idx = filename.lastIndexOf('.');
return idx !== -1 ? filename.slice(idx + 1).toLowerCase() : '';
};
const multiplier = sortDir === 'asc' ? 1 : -1;
return [...rows].sort((a, b) => {
let diff = 0;
if (sortBy === 'taken') {
diff = a.taken - b.taken;
} else if (sortBy === 'mtime') {
diff = (a.mtime ?? a.taken) - (b.mtime ?? b.taken);
} else if (sortBy === 'name') {
diff = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
} else if (sortBy === 'type') {
const extA = getExt(a.name);
const extB = getExt(b.name);
diff = extA.localeCompare(extB) || a.name.localeCompare(b.name);
} else if (sortBy === 'star') {
diff = (a.star ?? 0) - (b.star ?? 0);
}
if (diff === 0) {
diff = a.taken - b.taken;
}
return diff * multiplier;
});
}, [shown, minStar, year, from, to, sortBy, sortDir]);
const frame = listed.find((p) => p.id === up) ?? listed[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(listed.length, view === 'preview');
const drawn = useMemo(
() => listed.slice(Math.min(strip.from, listed.length), Math.min(strip.to, listed.length)),
[listed, 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. Scoped to
// `shown` — the open node (+ its subfolders when that chip is on) — not the
// whole library, so picking a year never points at frames elsewhere.
const years = useMemo(
() => [...new Set(shown.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a),
[shown]
);
// A year the open folder does not hold is not a year to look at, and leaving
// it set would draw an empty shelf with no way back but the dropdown. Fires on
// a move, not on every frame landing, so a scan of the open folder filling in
// its rows cannot clear a year the reader just picked.
useEffect(() => {
if (year !== 'all' && !years.includes(Number(year))) setYear('all');
// ponytail: only the year option list is folder-derived; stars and hours are
// fixed lists that cannot go stale. Re-check the others if they ever grow.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node, deep, rootName]);
const handleSelectPhoto = useCallback(
(photo: LibraryPhoto, e: React.MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
setSelectedIds((prev) => {
const next = new Set(prev);
if (isShift && lastSelectedId) {
const currentIndex = listed.findIndex((p) => p.id === photo.id);
const lastIndex = listed.findIndex((p) => p.id === lastSelectedId);
if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
for (let i = start; i <= end; i++) {
next.add(listed[i].id);
}
} else {
next.add(photo.id);
}
} else if (isCtrl) {
if (next.has(photo.id)) {
next.delete(photo.id);
} else {
next.add(photo.id);
}
} else {
next.clear();
next.add(photo.id);
}
return next;
});
setLastSelectedId(photo.id);
},
[listed, lastSelectedId]
);
const handlePhotoContextMenu = useCallback((photo: LibraryPhoto, e: React.MouseEvent) => {
e.preventDefault();
setSelectedIds((prev) => {
if (prev.has(photo.id)) {
return prev;
} else {
return new Set([photo.id]);
}
});
setPhotoMenu({
photo,
x: Math.min(e.clientX, window.innerWidth - 220),
y: Math.min(e.clientY, window.innerHeight - 240),
});
}, []);
// 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');
// Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
// effect on the array it is handed, so a fresh slice every render made that
// effect run every render — a `setUrls` per pass, a render per `setUrls`, and
// thousands of passes a second in which the tiles of the rows scrolled to never
// land at all.
const painted = useMemo(
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
[listed, wall.from, wall.to]
);
// A tile the studio graded while this screen was dark: the copy in hand is the
// negative's, and `onThumbWritten` hands over the one that replaces it. The
// counter is what makes the rows look again — the list is unchanged, so nothing
// else would make them.
const [tilesAt, setTilesAt] = useState(0);
useEffect(
() =>
onThumbWritten((id, blob) => {
cacheObjectUrl(id, blob);
setTilesAt((n) => n + 1);
// The studio grades a frame behind this screen's back, and what it files
// there is a filed look: the stage reads that off this set (`stageSrc`),
// and the row under the frame reads it for its own note. The set is the
// only thing here that is not already told.
setEdited((prev) => (prev.has(id) ? prev : new Set(prev).add(id)));
}),
[]
);
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
// 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);
// The same frame for the wall: the strip hands it over on the way to ALL
// THUMBS, and the wall scrolls to that frame's card rather than opening at the
// top of a shelf the reader was somewhere in the middle of.
const centredRef = useRef<string | null>(null);
useEffect(() => {
if (view !== 'preview' || !frame || !listed.length) return;
const idx = listed.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, listed, node, strip.ref]);
// And the wall's half of it: the frame the strip was on, brought to the middle
// of the wall. The card is not in the DOM to begin with — the wall only paints
// the rows around its viewport — so the first passes go to where the row should
// be and the ones after that read the card itself, which is the only honest
// measure: a row stands as tall as its longest caption. The loop is bounded
// rather than driven by state, because the wall reports its window by
// re-rendering and a scroll set inside a render is a scroll the next render
// undoes.
useEffect(() => {
if (view !== 'grid') return;
const id = centredRef.current;
const el = wall.ref.current;
if (!id || !el) return;
const idx = listed.findIndex((p) => p.id === id);
if (idx < 0) {
centredRef.current = null;
return;
}
let alive = true;
let left = 8;
const settle = () => {
if (!alive) return;
const card = el.querySelector<HTMLElement>(`[data-key="lib-photo-${CSS.escape(id)}"]`);
if (card) {
const box = el.getBoundingClientRect();
const own = card.getBoundingClientRect();
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
} else {
// No card drawn: the wall opens on the first screenful, so aim at the row
// and let the next pass find it. The row's step is read off a drawn card
// when there is one, and guessed at when there is not.
const first = el.querySelector<HTMLElement>(':scope > .adm-card');
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2);
}
if (--left > 0) requestAnimationFrame(settle);
else centredRef.current = null;
};
requestAnimationFrame(settle);
return () => {
alive = false;
};
// `up` is in the list for the frame the studio hands back: it is the one that
// moves when the shelf comes up on another frame, and a wall standing where it
// already was is a wall that would not follow it.
}, [view, listed, wall.ref, up]);
// This screen's own comings and goings: it never unmounts, so `here` is the
// only sign of the shelf being the screen up again (see `here` above).
useEffect(() => {
const onPop = () => setHere(window.location.pathname.startsWith('/library'));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Coming back from the studio, the shelf opens on the frame the studio was
// holding rather than on wherever the tree was left — that frame is the one the
// reader has just been working on. Both views are sent to it: the strip centres
// it and the wall, if that is the view up, scrolls to its card. It may also sit
// in another folder of the same tree (a frame id and a tree key are spelled the
// same way), and a frame the open node cannot list is a frame the shelf cannot
// raise at all, so the folder goes with it.
const wasHere = useRef(here);
useEffect(() => {
const came = here && !wasHere.current;
wasHere.current = here;
if (!came) return;
const last = recall(LAST_KEY);
if (!last) return;
setUp(last);
scrolledFrameRef.current = null;
centredRef.current = last;
const dir = last.slice(0, last.lastIndexOf('/'));
if (dir && nodes.some((n) => n.key === dir)) setNode(dir);
}, [here, nodes]);
// The way back, and the way in. The card the reader picked is the frame that
// goes up: a click on the wall is a pick, so the picked frame is the one the
// eye is on and the one the strip centres. Only with nothing picked does the
// card nearest the middle stand in for it — the wall opens where it was left,
// so the middle of it is where the reader was looking.
//
// `lastSelectedId` rather than the set: a shift-click stacks a run of frames on
// the card that was actually pressed, and that card is the one to raise. It is
// looked up in `listed` too, because a pick made in another folder is not a
// frame this shelf can raise.
const openFromWall = () => {
const el = wall.ref.current;
const picked =
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
? lastSelectedId
: null;
const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0;
let best: { id: string; d: number } | null = null;
for (const card of el ? el.querySelectorAll<HTMLElement>(':scope > .adm-card') : []) {
const box = card.getBoundingClientRect();
const d = Math.abs(box.top + box.height / 2 - mid);
if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d };
}
if (picked) setUp(picked);
else if (best) setUp(best.id);
// The strip is built afresh when it comes back, so the frame is centred again
// even when it is the frame it was already on.
scrolledFrameRef.current = null;
setView('preview');
};
// 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)));
}, []);
// The frame's own standing, filed the moment it is turned: the row is what the
// next visit, the next scan and the studio all read the angle off, so the turn
// is written down rather than held for the move to the next frame — a tab
// closed on a turned frame still knows the angle it was left at.
const turn = useCallback(async (photo: LibraryPhoto, dir: 1 | -1) => {
const next = ((((photo.rot ?? 0) + dir * 90) % 360) + 360) % 360 as 0 | 90 | 180 | 270;
await setRotation(photo.id, next);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, rot: next } : 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 and pan belong to the negative
// that was up before it.
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, [frame?.id]);
// Wheel zoom over the single view stage: zooms in/out relative to cursor position
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (!e.deltaY) return;
e.preventDefault();
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
const notches = Math.max(-3, Math.min(3, -e.deltaY / 100));
setZoom((zPrev) => {
const zNext = Math.min(ZOOM_MAX, Math.max(1, zPrev * ZOOM_STEP ** notches));
if (zNext === 1) {
setPan({ x: 0, y: 0 });
return 1;
}
const scaleFactor = zNext / zPrev;
setPan((pPrev) => ({
x: mouseX - (mouseX - pPrev.x) * scaleFactor,
y: mouseY - (mouseY - pPrev.y) * scaleFactor,
}));
return zNext;
});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('wheel', onWheel);
};
}, [view, frame?.id]);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
if (zoom <= 1) return;
isDraggingRef.current = false;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: pan.x,
panY: pan.y,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDraggingRef.current = true;
}
setPan({
x: dragStartRef.current.panX + dx,
y: dragStartRef.current.panY + dy,
});
};
const handlePointerUp = (e: React.PointerEvent) => {
if (dragStartRef.current) {
dragStartRef.current = null;
}
};
const handleStageDoubleClick = (e: React.MouseEvent) => {
if (zoom > 1) {
setZoom(1);
setPan({ x: 0, y: 0 });
} else {
const el = viewportRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
const zNext = 2.5;
setZoom(zNext);
setPan({
x: mouseX - mouseX * zNext,
y: mouseY - mouseY * zNext,
});
}
};
const [stageUrl, setStageUrl] = useState<string | null>(null);
// The frame that URL was read for. The raise moves on a render before the URL
// is dropped, so without this the frame that has just come up is handed the
// negative of the one before it — a URL the effect below is about to revoke.
const [stageFor, setStageFor] = useState<string | null>(null);
const stageUrlRef = useRef<string | null>(null);
// Only an object URL this page made is this page's to drop: a server's preview
// is an address the browser owns, and revoking it would be revoking nothing.
const revokeStageUrl = () => {
const url = stageUrlRef.current;
stageUrlRef.current = null;
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
};
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
useEffect(() => {
revokeStageUrl();
setStageUrl(null);
setStageFor(null);
if (!frame) return;
// A server's frame is not read off a disk: the stage draws the preview Immich
// already keeps, a screen-sized copy, rather than pulling the original through
// the proxy for a picture on a screen. A frame with no right to download has no
// original to fetch at all, and this is the road it is viewed by.
if (frame.remoteId) {
stageUrlRef.current = remoteUrl('preview', frame);
setStageUrl(stageUrlRef.current);
setStageFor(frame.id);
return;
}
let live = true;
void (async () => {
try {
const file = await readPhotoFile(frame);
if (!live) return;
let url: string | null = null;
if (isHeicName(file.name)) {
// The tile is the phone's own 320×240 copy; the stage wants more, so
// the frame itself is decoded and capped at what a screen shows (see
// heicWorker.ts — the decode is about a second for a 12MP frame, and
// the tile stands in until it lands).
const jpeg = await heicToJpeg(new Uint8Array(await file.arrayBuffer()), 2048);
if (live) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' }));
} else if (isTiffName(file.name)) {
// A TIFF is nobody's decoder here either (see `tiffThumbnail`), and the
// stage is the frame itself rather than the wall's tile, so it is
// decoded at what a screen shows.
const jpeg = await tiffDevelop(new Uint8Array(await file.arrayBuffer()), 2048);
if (live && jpeg) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' }));
} else if (isRawName(file.name)) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (!live) return;
const previewBytes = await rawThumbnail(bytes, file.name);
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);
setStageFor(frame.id);
} else if (url) {
URL.revokeObjectURL(url);
}
} catch {
// Fall back to thumb tile URL if file reading fails
}
})();
return () => {
live = false;
revokeStageUrl();
};
}, [frame?.id]);
// What the stage hands the frame on as: the file's own pixels, turned by the
// angle the row carries. The raised frame's own tile stands in until its file
// has been read, and a URL read for another frame is not the frame's at all.
// … and the one exception to it: a frame the studio has graded. The file is the
// negative that frame's tile was made from, so a stage read from the file would
// show a look the tile beside it is not showing — the reader is looking at the
// tile. A graded frame is drawn from its own tile, which is the same picture by
// the same pixels rather than a second develop of it.
// ponytail: the tile is 640px (`THUMB_MAX`), so a stage drawn wider than that is
// soft. Upgrade by developing the frame here, the way the studio does.
const gradedTile = frame && edited.has(frame.id) ? urls[frame.id] ?? null : null;
const stageSrc = gradedTile ?? (stageUrl && stageFor === frame?.id ? stageUrl : frame ? urls[frame.id] ?? null : null);
const stageTurned = useTurned(stageSrc, frame?.rot ?? 0);
// The tile: the thumbnail the catalogue kept, or — the first time a frame is
// drawn — the one the wall makes and keeps for the visits after. A frame whose
// own small copy the file never carried says so while its tile is made: a RAW
// the camera wrote no preview in, a HEIC with no thumbnail item (see
// engine/heicDevelop.ts). 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] ? (
<TileImg
src={urls[photo.id]!}
rot={photo.rot ?? 0}
onPick={onPick}
title={onPick ? t('lib.open') : undefined}
/>
) : (
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
{photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : isHeicName(photo.name) ? 'HEIC' : ''}
</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>
{/* The note is not drawn here any more. It is one line that comes and goes
— every scan and every press writes it — and a line in the flow above
the frame moved the frame, the column and the strip up and down by its
own height every time it appeared or went. It sits under the lot now,
in a row that is always there: see `.lib-status`. */}
{canBrowseFolders() || servers.length ? (
<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">
{canBrowseFolders() ? (
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
{t('lib.add')}
</button>
) : null}
{/* The second source: a server the account keeps, read through the
backend's own routes. It sits beside ADD FOLDER because that is
what it is to the reader — one more place to look — and it is
offered whether or not this browser can open a folder at all. */}
<button
type="button"
className="btn"
data-key="lib-add-immich"
onClick={() => setAddImmich(true)}
>
{t('lib.immich.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 — and
both are the disk's, so neither is offered where no folder can
be picked. */}
{canBrowseFolders() ? (
<>
<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>
</>
) : null}
{/* The repair, for the account the API calls an admin: the catalogue
this screen draws is the browser's own, and a catalogue that has
drifted — counts under folders the disk emptied, rows for frames
that are gone — is put back by reading every roll to the disk
again. It is the one control here that is a long job by design,
so it asks first and it says how far it got. */}
{admin ? (
<button
type="button"
className="btn"
data-key="lib-repair"
disabled={backBusy || Boolean(live) || scanQueue().length > 0}
onClick={() => void repair()}
>
{t('lib.repair')}
</button>
) : null}
{/* 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`. */}
{live && live.progress.total ? (
<span className="hint" data-key="lib-progress">
{t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added })}
</span>
) : resume && !live ? (
// The reading that was cut off when the app closed, kept in the
// reader's own time: one press picks it up where it stopped. It
// says nothing while a reading runs — the line above is the report.
<button
type="button"
className="chip"
data-key="lib-resume"
title={t('lib.resumeHint')}
onClick={() => {
const folder = folders.find((f) => f.name === resume);
setResume(null);
if (folder) void scan(folder, false);
}}
>
<span className="chip-label">
{/* `resume` is the folder's own name, which is what the store
is keyed by; the chip reads the label the reader gave it,
because a drive root's own name is `\`. */}
{t('lib.resume', { folder: folders.find((f) => f.name === resume)?.label || resume })}
</span>
</button>
) : null}
<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={openFromWall}
>
<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={() => {
centredRef.current = frame?.id ?? null;
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>
{libraryBlocked ? (
<p className="hint adm-note" data-key="lib-blocked">
{t('lib.blocked')}
</p>
) : null}
{/* 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) => {
// The row's folder, found the way the row's key is spelled:
// `normPath` on both sides, since a folder that came in from
// another machine carries that machine's separators.
const folder =
folders.find((f) => {
const fNorm = normPath(f.name);
return n.key === fNorm || n.key.startsWith(`${fNorm}/`);
}) ?? folders[0] ?? null;
// The row that says a reading is under way, and the row that
// says a request is waiting its turn — see `wearsMark` for
// which rows those are. The ring is worn from the click, not
// from the first frame in hand: the walk reads the catalogue
// and the position back before it touches the disk, and a
// command that shows nothing at all for that while is a
// command the reader cannot tell from one that never ran.
const scanning = !!liveMark && wearsMark(n, liveMark);
const waiting = !scanning && askedMarks.some((m) => wearsMark(n, m));
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={folder?.source === 'immich' ? t('lib.immich.hint') : 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) => {
e.preventDefault();
e.stopPropagation();
const targetFolder = folder || folders[0];
if (!targetFolder) return;
// The subfolder's path, taken off the row's key with the
// same spelling the row's key was built from — a
// separator left in a name would cut the path in the
// wrong place, and a reading asked of a folder that is
// not there reads nothing at all.
const fNorm = normPath(targetFolder.name);
const sub = n.key.length > fNorm.length ? n.key.slice(fNorm.length + 1) : '';
setMenu({
folder: targetFolder,
root: n.depth === 0,
rel: sub ? `${sub}/` : '',
node: n.key,
x: Math.min(e.clientX, window.innerWidth - 160),
y: Math.min(e.clientY, window.innerHeight - 200),
});
}}
>
<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">
{/* 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="lib-filters">
{/* 1. Ascending / Descending (Đầu hàng) */}
<button
type="button"
className="adm-input"
data-key="lib-sort-dir"
title={sortDir === 'desc' ? t('lib.sortDesc') : t('lib.sortAsc')}
onClick={() => setSortDir((prev) => (prev === 'desc' ? 'asc' : 'desc'))}
style={{ minWidth: '36px', padding: '0 6px', textAlign: 'center', cursor: 'pointer', fontWeight: 600, flexShrink: 0 }}
>
{sortDir === 'desc' ? '⬇' : '⬆'}
</button>
{/* 2. Date taken / Sort By */}
<select
className="adm-input"
data-key="lib-sort-by"
aria-label={t('lib.sort')}
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
style={{ flexShrink: 0 }}
>
<option value="taken">{t('lib.sortDateTaken')}</option>
<option value="mtime">{t('lib.sortDateEdited')}</option>
<option value="type">{t('lib.sortFileType')}</option>
<option value="name">{t('lib.sortName')}</option>
<option value="star">{t('lib.sortStars')}</option>
</select>
{/* 3. Year */}
<select
className="adm-input"
data-key="lib-filter-year"
aria-label={t('lib.year')}
value={year}
onChange={(e) => setYear(e.target.value)}
style={{ flexShrink: 0 }}
>
<option value="all">{t('lib.yearAll')}</option>
{years.map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
{/* 4. Time taken (From / To dropdown selects) */}
<TimeRangeSelectInput from={from} to={to} onChange={(f: string, tStr: string) => { setFrom(f); setTo(tStr); }} t={t} />
{/* 5. Rating (Cuối cùng) */}
<select
className="adm-input"
data-key="lib-filter-star"
aria-label={t('lib.rating')}
value={String(minStar)}
onChange={(e) => setMinStar(Number(e.target.value))}
style={{ flexShrink: 0 }}
>
<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>
{/* 6. Search: a name or a tag. The one control here that
leaves the shelf — the four before it look through the
frames already in hand, this one asks Immich for frames
that are not. Enter, not every keystroke: a node per
prefix of a word would be a column of dead ends. */}
<input
className="adm-input"
data-key="lib-search"
type="search"
aria-label={t('lib.search.label')}
placeholder={t('lib.search.placeholder')}
title={t('lib.search.hint')}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
void findImmich(search);
}}
style={{ flexShrink: 0 }}
/>
{/* 7. Count */}
<span className="hint" data-key="lib-listed" style={{ flexShrink: 0, marginLeft: 'auto' }}>
{t('lib.count', { n: listed.length })}
</span>
</div>
{view === 'grid' ? (
<>
{/* 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${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
<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>
</article>
))}
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
</div>
</>
) : frame ? (
<>
<div
className="adm-stage-viewport"
ref={viewportRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={handleStageDoubleClick}
style={{
flex: '1 1 auto',
minHeight: 0,
width: '100%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
cursor: zoom > 1 ? (dragStartRef.current ? 'grabbing' : 'grab') : 'pointer',
userSelect: 'none',
touchAction: 'none',
}}
>
{stageTurned ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
src={stageTurned}
alt=""
draggable={false}
onClick={() => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
return;
}
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={{
width: '100%',
height: '100%',
objectFit: 'contain',
transform: zoom === 1 && pan.x === 0 && pan.y === 0 ? undefined : `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: dragStartRef.current ? 'none' : 'transform 0.05s ease-out',
}}
/>
) : (
<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>
<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>
{/* The frame's own standing: a negative shot on its side
is turned here, once, and the catalogue keeps the
angle — the next visit, the next scan and the studio
all read it off the row, so it is never turned
twice. Two buttons and not a dial: a quarter is the
only turn a photo is ever out by. They sit on the
centre of the bar, over the middle of the picture
above them, because they are what the eye comes down
to after it; the score stays out at the edge. */}
<button
type="button"
className="chip lib-icon lib-turn"
data-key="lib-turn-left"
aria-label={t('lib.rotateLeft')}
title={t('lib.rotateLeft')}
onClick={() => void turn(frame, -1)}
>
<svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M3.2 6.2A5.2 5.2 0 1 1 3.5 10" />
<path d="M1.6 3.1v3.4h3.4" />
</svg>
</button>
<button
type="button"
className="chip lib-icon lib-turn"
data-key="lib-turn-right"
aria-label={t('lib.rotateRight')}
title={t('lib.rotateRight')}
onClick={() => void turn(frame, 1)}
>
<svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M12.8 6.2A5.2 5.2 0 1 0 12.5 10" />
<path d="M14.4 3.1v3.4h-3.4" />
</svg>
</button>
{/* 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' : ''}${selectedIds.has(photo.id) ? ' selected' : ''}`}
data-key={`lib-thumb-${photo.id}`}
title={photo.name}
onClick={(e) => {
setUp(photo.id);
handleSelectPhoto(photo, e);
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
<span className="adm-thumb-cap">{photo.name}</span>
</button>
))}
{listed.length - strip.to > 0 ? (
<span className="adm-thumb-gap" style={{ flexBasis: (listed.length - strip.to) * TILE_STEP - 8 }} aria-hidden="true" />
) : null}
{listed.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 second source is not the disk's and is not refused by a browser
that will not hand a page a folder: a server is one key, and every
browser can hold one. So it stands here too, outside the gate the
folder's own controls live behind. */}
<button type="button" className="btn" data-key="lib-add-immich" onClick={() => setAddImmich(true)}>
{t('lib.immich.add')}
</button>
</>
)}
{/* 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 ? (
menu.folder.source === 'immich' ? (
<>
{/* A server's node carries the server's own four things: read it
again, change what it keeps, drop the key, or leave the row — and
never a rescan, a rename or a tile job, which are the disk's. */}
<button
type="button"
className="lib-menu-item"
data-key={`lib-immich-sync-${menu.folder.name}`}
onClick={() => {
setMenu(null);
void syncServer(menu.folder!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{t('lib.immich.sync')}
</button>
{menu.folder.kind !== 'share' ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-immich-albums-${menu.folder.name}`}
onClick={() => {
setEditAlbums(menu.folder!);
setMenu(null);
}}
>
<span className="lib-menu-icon" aria-hidden="true">▤</span>
{t('lib.immich.albumsChoose')}
</button>
) : null}
<button
type="button"
className="lib-menu-item"
data-key={`lib-immich-off-${menu.folder.name}`}
onClick={() => {
setMenu(null);
void disconnect(menu.folder!);
}}
>
<span className="lib-menu-icon" aria-hidden="true">⚿</span>
{t('lib.immich.disconnect')}
</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.immich.keep')}
</button>
</>
) : (ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? (
<>
{!menu.root ? (
<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>
) : null}
{/* Four things can be asked of a roll: bring it up to date, read it
from the top again, give it the tiles it is short of, 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 others 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>
{/* The tiles this roll is short of, made from the frames themselves —
the same job the screen runs when a folder is opened, asked for by
hand on the row under the pointer, and over what lies under that
row: a folder is read whole or not at all. */}
<button
type="button"
className="lib-menu-item"
data-key={`lib-thumbs-${menu.folder.name}`}
onClick={() => {
const folder = menu.folder!;
const rel = menu.rel ?? '';
setMenu(null);
void makeThumbsFor(folder, rel, { loud: true });
}}
>
<span className="lib-menu-icon" aria-hidden="true">▤</span>
{t('lib.makeThumbs')}
</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()}
>
{selectedIds.size >= 2 ? (
<div style={{ padding: '4px 10px 6px', fontSize: '11px', fontWeight: 600, color: 'var(--accent)', borderBottom: '1px solid var(--border-soft)' }}>
{t('lib.count', { n: selectedIds.size })}
</div>
) : null}
{/* The frame's own look, taken back: what the studio filed for it and the
tile it drew from it, so the shelf shows the picture the frame came in
with — the tile as it was first drawn, and the preview the file's own
develop gives. */}
<button
type="button"
className="lib-menu-item"
data-key="lib-photo-reset"
onClick={() => {
const photo = photoMenu.photo;
setPhotoMenu(null);
void resetSetting(photo);
}}
>
<span className="lib-menu-icon" aria-hidden="true">↺</span>
{t('lib.reset')}
</button>
<button
type="button"
className="lib-menu-item"
data-key="lib-photo-merge-panorama"
onClick={() => {
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
setPhotoMenu(null);
setNote(`${t('lib.mergePanorama')} (${count} photos)`);
}}
>
<span className="lib-menu-icon" aria-hidden="true">🏞️</span>
{t('lib.mergePanorama')}
</button>
<button
type="button"
className="lib-menu-item"
data-key="lib-photo-merge-hdr"
onClick={() => {
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
setPhotoMenu(null);
setNote(`${t('lib.mergeHdr')} (${count} photos)`);
}}
>
<span className="lib-menu-icon" aria-hidden="true">🌄</span>
{t('lib.mergeHdr')}
</button>
<div style={{ height: '1px', background: 'var(--border-soft)', margin: '4px 0' }} />
<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 {
const res = await api.openExplorer(fullPath).catch(() => null);
if (res?.ok) {
setNote(t('lib.scanned', { folder: fullPath, added: 0, total: 1 }));
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(fullPath);
setNote(t('lib.copiedPath', { path: fullPath }));
}
} catch {
if (navigator.clipboard) {
await navigator.clipboard.writeText(fullPath);
setNote(t('lib.copiedPath', { path: fullPath }));
}
}
}}
>
<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;
});
setTimeout(() => {
const el = document.querySelector(`[data-key="lib-node-${targetNode}"]`) || document.querySelector(`[data-key="lib-node-${photo.folder}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('lib-folder-highlight');
setTimeout(() => el.classList.remove('lib-folder-highlight'), 2000);
}
}, 120);
}}
>
<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}
{/* The two dialogs of the second source: one adding a server, one changing
what an existing node keeps. They are the only place a key is typed, and
it goes straight up to the backend, which probes it before it stores. */}
{addImmich ? (
<ImmichDialog
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
) : null}
{editAlbums ? (
<ImmichDialog
server={servers.find((s) => s.id === editAlbums.server) ?? null}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setEditAlbums(null)}
/>
) : null}
{/* The whole screen's one line of news, and the last thing in it: the frame
above keeps its height whether there is anything to say or not, so a
scan starting or ending no longer moves the wall. `:empty` hides the
text without taking the row away. */}
<p className="hint lib-status" data-key="lib-status" aria-live="polite" title={note ?? undefined}>{note}</p>
</div>
);
}
// A frame's own pixels at the angle its row carries. The turn is baked into the
// bitmap rather than put on the element, because everything drawn over the photo
// on this stage measures that element: the wheel's zoom takes its aim from the
// element's own box and the frame is fitted to it, and both are read off a box
// the browser has already swapped the sides of once the element is turned. A
// quarter turn is a pixel swap — nothing is interpolated — so the canvas only
// pays the re-encode. The unturned frame stands while the turn is being made,
// which keeps a click from blanking the stage; and a frame at 0 hands the source
// straight on, so an unturned roll decodes nothing.
function useTurned(src: string | null, deg: number): string | null {
const [made, setMade] = useState<{ url: string; for: string } | null>(null);
useEffect(() => {
const key = `${src}|${deg}`;
if (!src || !deg) return;
let alive = true;
let url: string | null = null;
void (async () => {
try {
const bmp = await createImageBitmap(await (await fetch(src)).blob());
const swap = deg % 180 !== 0;
const canvas = new OffscreenCanvas(swap ? bmp.height : bmp.width, swap ? bmp.width : bmp.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
bmp.close();
return;
}
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((deg * Math.PI) / 180);
ctx.drawImage(bmp, -bmp.width / 2, -bmp.height / 2);
bmp.close();
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 });
if (!alive) return;
url = URL.createObjectURL(blob);
setMade({ url, for: key });
} catch {
// Undecodable here: the frame stands as the file wrote it.
}
})();
return () => {
alive = false;
if (url) URL.revokeObjectURL(url);
// The turn this run made goes with it. The element would otherwise be
// handed a revoked URL the moment the angle moves back to one this same
// source was already turned to.
setMade((prev) => (prev && prev.url === url ? null : prev));
};
}, [src, deg]);
return made && made.for === `${src}|${deg}` ? made.url : src;
}
// A tile, at the angle its row carries: the wall and the strip read the same
// turn the stage does, so a frame the reader has stood upright is not lying on
// its side two rows down. One canvas per turned frame and none at all for the
// rest — a tile at 0 hands its URL straight on.
function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick?: () => void; title?: string }) {
return <img src={useTurned(src, rot) ?? src} alt="" loading="lazy" onClick={onPick} title={title} />;
}
// One object URL per tile, made when the row does and dropped with it: a folder
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
const GLOBAL_URL_MAX = 600;
function cacheObjectUrl(id: string, blob: Blob): string {
const existing = globalThumbUrls.get(id);
if (existing && existing.blob === blob) {
return existing.url;
}
if (existing) {
URL.revokeObjectURL(existing.url);
} else if (globalThumbUrls.size >= GLOBAL_URL_MAX) {
const oldestKey = globalThumbUrls.keys().next().value;
if (oldestKey) {
const item = globalThumbUrls.get(oldestKey);
if (item) URL.revokeObjectURL(item.url);
globalThumbUrls.delete(oldestKey);
}
}
const url = URL.createObjectURL(blob);
globalThumbUrls.set(id, { url, blob });
return url;
}
// A tile the shelf is to draw again from the negative — the studio's grading taken
// back — is dropped here, the URL with it: the row is keyed on the tile the store
// holds, and a copy kept would answer every read after with the graded one.
function dropThumbUrl(id: string): void {
const held = globalThumbUrls.get(id);
if (!held) return;
URL.revokeObjectURL(held.url);
globalThumbUrls.delete(id);
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
const [urls, setUrls] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) initial[p.id] = cached.url;
}
return initial;
});
useEffect(() => {
let alive = true;
if (!list.length) {
setUrls({});
return;
}
const syncUrls: Record<string, string> = {};
const idsToFetch: string[] = [];
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) {
syncUrls[p.id] = cached.url;
} else {
idsToFetch.push(p.id);
}
}
if (Object.keys(syncUrls).length > 0) {
setUrls((prev) => ({ ...prev, ...syncUrls }));
}
if (idsToFetch.length === 0) return;
// The tile is `makeThumb`'s business: the store first, the backup behind it,
// and only then the file — a RAW's own preview, or a decode. A frame the disk
// will not hand over is a frame with no tile rather than a strip that stops.
//
// Every tile is drawn the moment it lands, and the file is worked a couple of
// frames at a time with the thread handed back between them. Waiting for the
// whole shelf and painting once at the end is a screen that shows nothing for
// seconds and answers nothing while it waits; this way the frames come in one
// after another and a click during the fill still lands.
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
const landed: Record<string, string> = {};
for (const photo of list) {
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
if (kept) landed[photo.id] = cacheObjectUrl(photo.id, kept);
}
if (alive && Object.keys(landed).length > 0) {
setUrls((prev) => ({ ...prev, ...landed }));
}
// A frame off the disk is one no server owns: its file is asked of the
// folder's handle when the tile is made, so there is no handle to test for.
const queue = list.filter((p) => !(p.id in landed) && !p.remoteId);
await Promise.all(
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
while (alive && queue.length) {
const photo = queue.shift()!;
const blob = await makeThumb(photo);
if (!alive) return;
if (blob) setUrls((prev) => ({ ...prev, [photo.id]: cacheObjectUrl(photo.id, blob) }));
await breathe();
}
})
);
});
return () => {
alive = false;
};
// `stamp` is a tile rewritten under this row: the list is the same one, and
// what has to happen again is the read of the store.
}, [list, stamp]);
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 }));
// The window last written, because the reader of a `setWin` in a layout effect
// is not the state: a set made from a layout effect renders again whether or
// not the value moved, and this one is remeasured after every render — so a set
// it did not need is the screen's own render loop (React #185, "Maximum update
// depth exceeded"). The ref is the guard: nothing is set that the wall already
// stands at.
const winRef = useRef(win);
const measureRef = useRef<() => void>(() => {});
// The step from one row to the next, taken once per column count and kept. It
// is a ref rather than a value read on every pass because the pass reads it off
// the rows the window drew — and a step that moves with the window is a window
// that moves with the step: the two trade places forever (see `measure`).
const pitchRef = useRef(0);
const colsRef = useRef(0);
// 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;
// The window is only ever set through here: a `setWin` the wall already
// stands at is a render bought for nothing, and this is measured after every
// render — that is the loop (React #185).
// And the second guard, for a reader the first one cannot see: a window
// written off the window before it is a window that can write itself, and
// whatever the shelf does between the two there are only so many turns of
// that before React gives up with #185 — a blank screen, this app having no
// error boundary between it and the floor. `ponytail:` past the count the
// wall stands still, one window behind, until something outside moves — the
// next scroll, resize, or count hands the pen straight back.
let carries = 0;
const show = (next: { from: number; to: number; above: number; below: number }) => {
const prev = winRef.current;
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
if (carries >= WALL_CARRIES) return;
carries++;
winRef.current = next;
setWin(next);
};
const measure = () => {
// A wall that is not standing has no box to measure: the library is
// `display:none` while the studio is up, and a window read off it would be
// a window read off nothing — every card reports `offsetTop` 0 and every
// scroll 0 — which threw the shelf back to its top every time the studio
// handed back to it. It is read on the frame it comes back up, which is a
// box change.
if (!el.clientHeight) return;
// And a window that is nowhere on the shelf stands nowhere: outside a shelf
// that shrank under it — another folder, a filter, a shorter roll — or empty
// while the shelf is not, which is the first paint, before the catalogue has
// landed. This is the one reset, and it is written off the shelf's own
// length rather than off the cards the last window drew, which is what made
// the reset a window the window fed.
const held = winRef.current.to - winRef.current.from;
if (held <= 0 || winRef.current.from >= count || winRef.current.to > count) {
show({ from: 0, to: Math.min(count, 24), above: 0, below: 0 });
return;
}
// How many columns is the grid's own business, read off its layout and not
// counted off the cards the window drew: the drawn run is a whole number of
// rows, those rows were read back into the count, and a count read off the
// run is a count the window feeds itself with. This is the same read the
// settle effect above scrolls the wall by.
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
// The run the step below is read off. A run of a single row is no run at
// all — that is the last row of a shelf scrolled to its end — and a wall
// that has drawn nothing is nothing to read: the state's own first
// screenful stands until there is one.
const cards = el.querySelectorAll<HTMLElement>(':scope > .adm-card');
const rowsDrawn = Math.floor(cards.length / cols);
if (rowsDrawn < 2) 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.
// The step is read off the drawn rows the first time the wall stands at this
// many columns, and kept until it stands at another: the average of a run of
// rows moves as the run grows, so a step re-read on every pass made the window
// depend on itself — `to` traded places with a second sum a row away, sixty
// times a second, until React gave up with #185 and the screen went blank. One
// step, one window. It is re-read when the wall re-columns, the only thing that
// changes a card's own height (a caption wraps at the width it is given); a
// resize that does not re-column keeps the step, and a spacer a few pixels out
// is a scrollbar a few pixels out, not a blank screen.
if (colsRef.current !== cols) {
const drawn = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1);
if (drawn > 0) {
colsRef.current = cols;
pitchRef.current = drawn;
}
}
const pitch = pitchRef.current;
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;
show({ from, to, above, below });
};
measureRef.current = measure;
const onOutside = () => {
carries = 0;
measure();
};
el.addEventListener('scroll', onOutside, { passive: true });
const ro = new ResizeObserver(onOutside);
ro.observe(el);
return () => {
measureRef.current = () => {};
el.removeEventListener('scroll', onOutside);
ro.disconnect();
};
}, [count, on]);
useLayoutEffect(() => {
measureRef.current();
});
return { ref, from: win.from, to: win.to, above: win.above, below: win.below };
}