91c9b1f95b
The column files a local folder as an absolute key — `/2012` — while a frame carries its own `dir` as a path from the root, `2012`. `shown` set the two against each other as they stood, so a frame's dir was never the node's dir, and never under it either: every folder in the disk tree was counted in the row above it and stood in no pane at all — the wall white, the frames nowhere. The key sheds the slash it leads with before the two are set against each other, the way `markOf` already cleans a reading's own path. Measured on the live account's own catalogue — 112761 frames, 6 under `/2012`: the node matched 0 frames before, 6 after; the root's own list is untouched (927).
4708 lines
212 KiB
TypeScript
4708 lines
212 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
||
import { useI18n } from './i18n/I18nProvider';
|
||
import { go } from './nav';
|
||
import { api, type User } 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 {
|
||
askPersistence,
|
||
breathe,
|
||
bySourceThenName,
|
||
cachedFolders,
|
||
cachedPhotos,
|
||
canBrowseFolders,
|
||
clearLibraryCache,
|
||
ensurePermission,
|
||
findFolderRelationship,
|
||
getCachedThumb,
|
||
getPhoto,
|
||
getPhotoThumbs,
|
||
HEAD_BYTES,
|
||
jumpScan,
|
||
listEditedIds,
|
||
listFolders,
|
||
listWalkedDirs,
|
||
makeThumb,
|
||
makeThumbs,
|
||
normPath,
|
||
onLibraryBlocked,
|
||
onThumbWritten,
|
||
pickFolder,
|
||
putFolder,
|
||
putPhotosFast,
|
||
readPhotoFile,
|
||
readPhotos,
|
||
reconnectPhotosForFolders,
|
||
remoteUrl,
|
||
removeFolder,
|
||
renameFolder,
|
||
resetEdit,
|
||
scanAsked,
|
||
scanBusy,
|
||
scanQueue,
|
||
scanSession,
|
||
setRotation,
|
||
setStar,
|
||
stopScan,
|
||
stopScanAt,
|
||
stopCount,
|
||
unfinished,
|
||
saveCachedPhotos,
|
||
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,
|
||
fetchImmichBuckets,
|
||
forgetImmichServer,
|
||
immichConfig,
|
||
listAssets,
|
||
saveImmichAlbums,
|
||
searchImmichFolder,
|
||
syncImmich,
|
||
type ImmichServer,
|
||
} from './engine/immich';
|
||
import { ImmichDialog } from './ui/ImmichDialog';
|
||
import { MobileLibrary } from './ui/MobileLibrary';
|
||
|
||
// 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, and the year, month, day and hours it was shot in — which are
|
||
// what 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;
|
||
folderName: string;
|
||
}
|
||
|
||
// 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 MONTH_KEY = 'recipescam.library.month';
|
||
const DAY_KEY = 'recipescam.library.day';
|
||
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 the wall leaves between two frames, and between two rows of them: the wall
|
||
// is one shelf of pictures and not a grid of cards, so the gap is the one a
|
||
// contact sheet leaves — enough to tell two frames apart, little enough that a
|
||
// day still reads as a day.
|
||
const WALL_GAP = 3;
|
||
// The height a row of frames is justified to, and the two bands the wall opens a
|
||
// run of frames with: the month's own line, then the day's. The bands are heights
|
||
// the style sheet fixes, and these are those two numbers (`.adm-month-head` /
|
||
// `.adm-day-head` in styles/app.css); `target` is the wall's own, since a row is
|
||
// laid out to the width it is given and not to a height. The shelf's whole height
|
||
// is built out of the three before a single frame is drawn, which is what keeps
|
||
// the scrollbar the length of the roll.
|
||
const ROW_TARGET = 168;
|
||
const MONTH_BAND = 44;
|
||
const DAY_BAND = 30;
|
||
// The shelf's own stacking, read off the layout in one place and nowhere else.
|
||
const WALL_STEPS: WallSteps = { head: MONTH_BAND, band: DAY_BAND, target: ROW_TARGET, gap: WALL_GAP };
|
||
// The line the eye stands on, a few pixels under the pane's own top edge: the row
|
||
// a reader is reading is the row that carries that line, not the row the pane
|
||
// happens to cut through, and a band standing a few pixels above the fold is the
|
||
// band the pane is in. The ruler's needle reads the month of that row, and a jump
|
||
// to a month leaves its band exactly on the line.
|
||
const WALL_EYE = 8;
|
||
// 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)} user={user} />;
|
||
}
|
||
|
||
import { useIsMobile } from './ui/useIsMobile';
|
||
import { dayKey, dayLabel, dayRow, monthKey, monthLabel, monthTitle, rowAt, rowOf, wallLayout, WALL_AR, yearKey, type WallRow, type WallSteps } from './ui/dateGroups';
|
||
|
||
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||
const { t, lang } = useI18n();
|
||
const [forceView, setForceViewState] = useState<'auto' | 'mobile' | 'desktop'>(() => {
|
||
return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto';
|
||
});
|
||
const setForceView = (v: 'auto' | 'mobile' | 'desktop') => {
|
||
setForceViewState(v);
|
||
localStorage.setItem('rc_force_view', v);
|
||
};
|
||
const detectedMobile = useIsMobile(860);
|
||
const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile);
|
||
|
||
// The phone frame belongs to a narrow window. A desktop browser that drags its
|
||
// title bar to the top of the screen, or presses maximise, is a window with the
|
||
// room for the desktop layout again — so the frame is let go, and only there:
|
||
// a phone never widens past the breakpoint, and a window still narrow keeps it.
|
||
useEffect(() => {
|
||
const onResize = () => {
|
||
if (forceView === 'mobile' && window.innerWidth > 860) setForceView('auto');
|
||
};
|
||
window.addEventListener('resize', onResize);
|
||
return () => window.removeEventListener('resize', onResize);
|
||
}, [forceView]);
|
||
|
||
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 [serverYears, setServerYears] = useState<number[]>([]);
|
||
const [addImmich, setAddImmich] = useState(false);
|
||
const [editServer, setEditServer] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!servers.length) return;
|
||
let alive = true;
|
||
Promise.all(servers.map((s) => fetchImmichBuckets(s.id, 'MONTH'))).then((results) => {
|
||
if (!alive) return;
|
||
const yrs = new Set<number>();
|
||
for (const bucketList of results) {
|
||
for (const b of bucketList) {
|
||
const yr = parseInt(b.timeBucket.slice(0, 4), 10);
|
||
if (yr > 1900 && yr < 2100) yrs.add(yr);
|
||
}
|
||
}
|
||
setServerYears(Array.from(yrs).sort((a, b) => b - a));
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [servers]);
|
||
// 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. On to begin with: a folder is its branch, so the root draws the
|
||
// frames of every folder under it — a server's own folder among them — and a
|
||
// frame two levels down is not a frame the reader has to go and find. The
|
||
// toggle keeps the last word, and the answer is picked up where the last visit
|
||
// left it, the same as the open node below.
|
||
const [deep, setDeep] = useState(() => recall(DEEP_KEY) !== 'false');
|
||
// The frame that is up in the open node — raised by a click on its thumbnail,
|
||
// and picked up where the last visit left it, so a reader who leaves for the
|
||
// studio comes back to the negative they were looking at rather than to the
|
||
// first one the node happens to hold. A frame that is gone falls through to
|
||
// the first, the way a node that is gone falls back to its folder.
|
||
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
|
||
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
|
||
// 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, this month, on
|
||
// this day, 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');
|
||
// Month and day of the shot, 'all' meaning every one. Numbers rather than
|
||
// names: a name is a language and this shelf is read in two of them.
|
||
const [month, setMonth] = useState(() => recall(MONTH_KEY) || 'all');
|
||
const [day, setDay] = useState(() => recall(DAY_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(MONTH_KEY, month); }, [month]);
|
||
useEffect(() => { keep(DAY_KEY, day); }, [day]);
|
||
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 server nodes a reading is in flight on, by folder name. See `syncServer`.
|
||
const syncingServer = useRef<Set<string>>(new Set());
|
||
// The server nodes this visit has already carried on reading. The restore below
|
||
// runs again whenever the node the screen shows changes, and a reading begun
|
||
// again on every click is a reading asking the server for pages it just had.
|
||
const startedHere = useRef<Set<string>>(new Set());
|
||
|
||
// 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 tiles this screen spends its first seconds making are the ones the visit
|
||
// after reads: the origin is asked to keep them (see `askPersistence`).
|
||
useEffect(() => {
|
||
askPersistence();
|
||
}, []);
|
||
|
||
// 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(undefined, (firstBatch) => {
|
||
setPhotos((prev) => (prev.length === 0 ? firstBatch : prev));
|
||
}),
|
||
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) => {
|
||
if (prev !== null) {
|
||
const normPrev = normPath(prev).toLowerCase();
|
||
const found = rows.find((f) => {
|
||
const fName = (f.name || '').toLowerCase();
|
||
const normF = normPath(f.name).toLowerCase();
|
||
return fName === normPrev || normF === normPrev || (normF && normPrev.startsWith(`${normF}/`));
|
||
});
|
||
if (found) return prev;
|
||
}
|
||
return rows[0] ? normPath(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 !== null) 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.
|
||
//
|
||
// A node of a hundred thousand frames is not read in one visit. What comes back
|
||
// here is the newest pages of it and a word that the rest is still coming, which
|
||
// is what the note says: the reader who is told six hundred frames were read is
|
||
// a reader looking at a node that says it holds six hundred.
|
||
const syncServer = useCallback(
|
||
async (folder: LibraryFolder) => {
|
||
// One reading of a node at a time. This is asked for on every visit and
|
||
// again whenever the node the screen shows changes, and a second reading
|
||
// behind the first is the same pages asked of the server twice over.
|
||
if (syncingServer.current.has(folder.name)) return;
|
||
syncingServer.current.add(folder.name);
|
||
setNote(null);
|
||
try {
|
||
const res = await syncImmich(folder, (batch, progress) => {
|
||
setPhotos((prev) => {
|
||
const existing = new Set(prev.map((p) => p.id));
|
||
const toAdd = batch.filter((p) => !existing.has(p.id));
|
||
if (!toAdd.length) return prev;
|
||
// A server's reading walks backwards in time, and a page carried on
|
||
// from where the last visit stopped is older than every row the screen
|
||
// already holds: put after them, the list is in order without being
|
||
// sorted, and sorting a hundred thousand rows once a page is the visit
|
||
// spending itself on sorting. Only a page the reading has gone back
|
||
// over — the newest ones, read again from the top — needs the sort.
|
||
const tail = prev[prev.length - 1];
|
||
const next =
|
||
tail && toAdd[0].taken <= tail.taken
|
||
? [...prev, ...toAdd]
|
||
: [...toAdd, ...prev].sort((a, b) => b.taken - a.taken);
|
||
saveCachedPhotos(next);
|
||
return next;
|
||
});
|
||
// The reading's own last word. The answer below is the foreground
|
||
// window's, and the pages behind it are read after it: the note that
|
||
// said a reading was running is spent here, when the last page of the
|
||
// whole walk lands, rather than never.
|
||
if (!progress.hasMore) {
|
||
setNote(t('lib.immich.added', { n: progress.loaded, folder: folder.label ?? folder.name }));
|
||
}
|
||
});
|
||
setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]);
|
||
setNote(
|
||
res.done
|
||
? t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name })
|
||
: t('lib.immich.reading', { folder: folder.label ?? folder.name })
|
||
);
|
||
await reload();
|
||
} catch {
|
||
setNote(t('lib.immich.failed'));
|
||
} finally {
|
||
syncingServer.current.delete(folder.name);
|
||
}
|
||
},
|
||
[reload, t]
|
||
);
|
||
|
||
// Automatically restore and reconcile cloud folders (Immich Cloud) for registered users.
|
||
// When a user adds an Immich server, it is saved on their account. When logging in on any
|
||
// device, this automatically restores the cloud folder and syncs its albums and photos.
|
||
useEffect(() => {
|
||
if (!loaded) return;
|
||
let alive = true;
|
||
|
||
void (async () => {
|
||
if (!servers.length) return;
|
||
|
||
const current = await listFolders();
|
||
let nextFolders = [...current];
|
||
let changed = false;
|
||
const taken = new Set(nextFolders.map((f) => f.name));
|
||
|
||
for (const server of servers) {
|
||
let folder = nextFolders.find(
|
||
(f) => f.source === 'immich' && f.server === server.id && f.kind !== 'search'
|
||
);
|
||
if (!folder) {
|
||
folder = await addImmichFolder(server, taken);
|
||
taken.add(folder.name);
|
||
nextFolders.push(folder);
|
||
changed = true;
|
||
void syncServer(folder);
|
||
} else if (JSON.stringify(folder.albums ?? []) !== JSON.stringify(server.albums ?? [])) {
|
||
folder = { ...folder, albums: server.albums };
|
||
await putFolder(folder);
|
||
nextFolders = nextFolders.map((f) => (f.name === folder!.name ? folder! : f));
|
||
changed = true;
|
||
void syncServer(folder);
|
||
} else if ((!folder.cursor || !folder.cursor.done) && !startedHere.current.has(folder.name)) {
|
||
// A node that was never read through is a node whose reading ended with
|
||
// the visit that began it — a reload, a closed tab, a server that did not
|
||
// answer — and the place it stopped at is on its own record. Carrying on
|
||
// from there is the only way the frames under the newest pages are ever
|
||
// read: the visit that reads three pages and stops, every visit and for
|
||
// good, is a node the reader is told holds six hundred frames.
|
||
startedHere.current.add(folder.name);
|
||
void syncServer(folder);
|
||
}
|
||
}
|
||
|
||
// Remove any local immich folders whose server is no longer in servers
|
||
const serverIds = new Set(servers.map((s) => s.id));
|
||
const removed = nextFolders.filter(
|
||
(f) => f.source === 'immich' && f.server && !serverIds.has(f.server)
|
||
);
|
||
if (removed.length > 0) {
|
||
for (const r of removed) {
|
||
await removeFolder(r.name);
|
||
}
|
||
nextFolders = nextFolders.filter((f) => !removed.some((rem) => rem.name === f.name));
|
||
changed = true;
|
||
}
|
||
|
||
if (changed && alive) {
|
||
nextFolders.sort(bySourceThenName);
|
||
setFolders(nextFolders);
|
||
setReady((prev) => {
|
||
const ok = new Set(prev);
|
||
for (const f of nextFolders) {
|
||
if (f.source === 'immich') ok.add(f.name);
|
||
}
|
||
return ok;
|
||
});
|
||
if (node === null && nextFolders[0]) {
|
||
setNode(normPath(nextFolders[0].name));
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [loaded, servers, syncServer, node]);
|
||
|
||
// 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 name, address or albums have
|
||
// just been changed. 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 one keeps
|
||
// its frames, and every node reading by it is walked again for the albums it now
|
||
// keeps — which is why the folder's own copy of that list has to be written before
|
||
// the sync reads it.
|
||
const serverSaved = useCallback(
|
||
async (saved: ImmichServer, isNew: boolean) => {
|
||
setAddImmich(false);
|
||
setEditServer(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) {
|
||
for (const f of await listFolders()) {
|
||
if (f.server !== saved.id) continue;
|
||
const next: LibraryFolder = { ...f, albums: saved.albums };
|
||
await putFolder(next);
|
||
await syncServer(next);
|
||
}
|
||
setFolders(await listFolders());
|
||
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);
|
||
},
|
||
[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]
|
||
);
|
||
|
||
// A saved link taken off the list in the settings dialog: the key goes, and
|
||
// every node that was reading by it goes with it — a node with no key is a row
|
||
// that could only fail. Same move as `disconnect`, for the case where the reader
|
||
// is looking at the list rather than at the node.
|
||
const forgetLink = useCallback(
|
||
async (id: string) => {
|
||
const gone = folders.filter((f) => f.server === id);
|
||
await forgetImmichServer(id);
|
||
for (const f of gone) await removeFolder(f.name);
|
||
const rest = folders.filter((f) => f.server !== id);
|
||
setFolders(rest);
|
||
setDirs((prev) => prev.filter((d) => !gone.some((f) => f.name === d.folder)));
|
||
if (gone.some((f) => f.name === node)) setNode(rest[0]?.name ?? null);
|
||
setServers((await immichConfig(true)).servers);
|
||
await reload();
|
||
},
|
||
[folders, node, reload]
|
||
);
|
||
|
||
// The frame goes to the studio through the address bar: the studio owns the
|
||
// load (develop, EXIF, session) and this page owns no part of it. While a scan
|
||
// is running the address bar is the one thing that changes — `go` keeps the
|
||
// reading where it is instead of reloading the page out from under it.
|
||
const open = useCallback(async (photo: LibraryPhoto) => {
|
||
// A cloud frame is read from its own server by the studio, so a row this
|
||
// store no longer holds is put back instead of dropping the click — the
|
||
// catalogue already has the frame, and the server has its bytes.
|
||
const row = (await getPhoto(photo.id)) ?? (photo.remoteId ? photo : null);
|
||
if (!row) return;
|
||
if (row === photo) await putPhotosFast([photo]);
|
||
// 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 (!folders.length) return null;
|
||
if (node !== null) {
|
||
const normNode = normPath(node).toLowerCase();
|
||
const match = folders.find((f) => {
|
||
const fName = (f.name || '').toLowerCase();
|
||
const normF = normPath(f.name).toLowerCase();
|
||
return fName === normNode || normF === normNode;
|
||
});
|
||
if (match) return match;
|
||
|
||
const sub = folders.find((f) => {
|
||
const normF = normPath(f.name).toLowerCase();
|
||
return normF ? (normNode === normF || normNode.startsWith(`${normF}/`)) : false;
|
||
});
|
||
if (sub) return sub;
|
||
|
||
const byLabel = folders.find((f) => (f.label || '').toLowerCase() === normNode);
|
||
if (byLabel) return byLabel;
|
||
|
||
const emptyRoot = folders.find((f) => normPath(f.name) === '');
|
||
if (emptyRoot) return emptyRoot;
|
||
}
|
||
return 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 => {
|
||
// `null` and not falsy: a picked drive root is named `\`, which folds to the
|
||
// empty root, and a guard on the name itself would take every row of such a
|
||
// roll for a row of another folder.
|
||
if (rootName === null) 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 === null || !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>();
|
||
// What a folder holds of its own, the frames filed straight into it. The two
|
||
// are one number for a folder of the disk, whose subfolders hold frames that
|
||
// are nowhere else; a server's album rows hold the frames the node above them
|
||
// holds too — an album is a second way into one library, not more of it — and
|
||
// a node's number read off `hits` would say the library twice. See `counted`.
|
||
const own = 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);
|
||
else own.set(normF.toLowerCase(), (own.get(normF.toLowerCase()) ?? 0) + 1);
|
||
}
|
||
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, own, foldersUnder };
|
||
}, [photos, dirs, liveDirs]);
|
||
|
||
const nodes = useMemo<Node[]>(() => {
|
||
const { hits, own, 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.
|
||
// A server's node is not on the disk, and the walk record is the disk's word:
|
||
// `syncImmich` writes down where it stopped and what the pages it read held,
|
||
// never the whole of the node — a reading that stops at its foreground window
|
||
// writes the six hundred frames of that window, and one whose first page does
|
||
// not come back writes a nought. Read as the node's own numbers they stand a
|
||
// half-count, or a nought, beside a node the catalogue holds a hundred
|
||
// thousand frames under, for good: the record outlives every visit. So a
|
||
// server's rows read their numbers off the catalogue, which is the one thing
|
||
// that knows what has been filed under them. See `engine/immich.ts`.
|
||
const serverRoots = new Set(
|
||
folders.filter((f) => f.source === 'immich').map((f) => normPath(f.name).toLowerCase())
|
||
);
|
||
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;
|
||
// The node itself: its own frames and no more. Its album rows sit under it
|
||
// and hold frames it holds as well, so the whole catalogue under the node is
|
||
// the library counted once per album one of its frames is in. The album rows
|
||
// are rows of their own and count themselves below.
|
||
if (serverRoots.has(lowerKey)) return own.get(lowerKey) ?? hits.get(lowerKey) ?? 0;
|
||
if (![...serverRoots].some((r) => lowerKey.startsWith(`${r}/`))) {
|
||
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),
|
||
folderName: folder.name,
|
||
});
|
||
// Path order puts a folder before its own children, which is the order the
|
||
// indented rows have to come in.
|
||
for (const dir of [...(foldersUnder.get(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),
|
||
folderName: folder.name,
|
||
});
|
||
}
|
||
}
|
||
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 === null || 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, while the toggle is on, the
|
||
// ones in every folder under it — the whole of the branch the row above it
|
||
// counted, a server's own folder among them.
|
||
const shown = useMemo(() => {
|
||
if (!rootFolder || rootName === null) return [];
|
||
const isImmich = rootFolder.source === 'immich';
|
||
const normRoot = normPath(rootName).toLowerCase();
|
||
const normNode = normPath(node ?? '').toLowerCase();
|
||
// A node's key is the path the column gave it, and a local root's is
|
||
// absolute — the disk's `/2012`, where the cloud's is `IMMICH2194` — while a
|
||
// frame's own `dir` is never absolute. The key sheds the slash it leads with
|
||
// before the two are set against each other, or every frame under a local
|
||
// folder counts in the row above it and stands in no pane at all.
|
||
const dir = (normNode === normRoot
|
||
? ''
|
||
: (normRoot ? (normNode.startsWith(`${normRoot}/`) ? normNode.slice(normRoot.length + 1) : normNode) : normNode)).replace(/^\/+/, '');
|
||
const below = dir === ''
|
||
? (p: LibraryPhoto) => normPath(p.dir ?? '') !== ''
|
||
: (p: LibraryPhoto) => normPath(p.dir ?? '').toLowerCase().startsWith(`${dir}/`);
|
||
const own: LibraryPhoto[] = [];
|
||
const both: LibraryPhoto[] = [];
|
||
for (const p of photos) {
|
||
if (isImmich) {
|
||
if (!p.server && !p.remoteId) continue;
|
||
if (rootFolder.server && p.server && p.server !== rootFolder.server) continue;
|
||
if (normPath(p.folder ?? '').toLowerCase() !== normRoot && p.folder !== rootFolder.name) continue;
|
||
} else {
|
||
if (p.server || p.remoteId) continue;
|
||
const pFolder = normPath(p.folder ?? '').toLowerCase();
|
||
const pIdLower = p.id.toLowerCase();
|
||
const matchesFolder =
|
||
pFolder === normRoot ||
|
||
p.folder === rootFolder.name ||
|
||
(normRoot !== '' && pIdLower.startsWith(`${normRoot}/`)) ||
|
||
(normRoot === '' && (pFolder === '' || pFolder === '\\'));
|
||
if (!matchesFolder) continue;
|
||
}
|
||
const pDir = normPath(p.dir ?? '').toLowerCase();
|
||
const isOwn = pDir === dir;
|
||
if (isOwn) own.push(p);
|
||
if (isOwn || below(p)) both.push(p);
|
||
}
|
||
return deep || !own.length ? both : own;
|
||
}, [deep, node, photos, rootFolder, 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 (month !== 'all' && at.getMonth() + 1 !== Number(month)) return false;
|
||
if (day !== 'all' && at.getDate() !== Number(day)) 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, month, day, 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(() => {
|
||
const set = new Set<number>();
|
||
for (const p of shown) {
|
||
if (p.taken) set.add(new Date(p.taken).getFullYear());
|
||
}
|
||
if (rootFolder?.source === 'immich') {
|
||
for (const yr of serverYears) {
|
||
set.add(yr);
|
||
}
|
||
}
|
||
return Array.from(set).sort((a, b) => b - a);
|
||
}, [shown, serverYears, rootFolder?.source]);
|
||
|
||
// 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, years]);
|
||
|
||
// On-demand fetch for selected year if user has Immich servers and local photos for that year are missing
|
||
const fetchYearPhotos = useCallback(
|
||
async (yr: number) => {
|
||
if (!servers.length) return;
|
||
const server = servers[0];
|
||
const immichFolder = folders.find((f) => f.source === 'immich' && f.server === server.id);
|
||
const root = normPath(immichFolder?.name ?? 'IMMICH');
|
||
const startDate = `${yr}-01-01T00:00:00.000Z`;
|
||
const endDate = `${yr}-12-31T23:59:59.999Z`;
|
||
|
||
try {
|
||
const { items } = await listAssets(server.id, null, 1, '', endDate, startDate);
|
||
if (!items.length) return;
|
||
|
||
const newPhotos: LibraryPhoto[] = items.map((item) => ({
|
||
id: [root, item.name].join('/'),
|
||
folder: immichFolder?.name ?? 'IMMICH',
|
||
dir: '',
|
||
name: item.name,
|
||
server: server.id,
|
||
remoteId: item.id,
|
||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||
size: item.size ?? 0,
|
||
addedAt: Date.now(),
|
||
}));
|
||
|
||
setPhotos((prev) => {
|
||
const have = new Set(prev.map((p) => p.id));
|
||
const landed = newPhotos.filter((p) => !have.has(p.id));
|
||
return landed.length ? [...prev, ...landed] : prev;
|
||
});
|
||
} catch {
|
||
// quiet
|
||
}
|
||
},
|
||
[servers, folders]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (year === 'all' || !servers.length || rootFolder?.source !== 'immich') return;
|
||
const yr = Number(year);
|
||
if (!Number.isFinite(yr) || yr < 1900) return;
|
||
|
||
const hasPhotosForYear = photos.some((p) => new Date(p.taken || 0).getFullYear() === yr);
|
||
if (!hasPhotosForYear) {
|
||
void fetchYearPhotos(yr);
|
||
}
|
||
}, [year, photos, servers, fetchYearPhotos, rootFolder?.source]);
|
||
|
||
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.
|
||
// Where each day of the shelf begins, cut by the same rule the bands are drawn
|
||
// by (`dayRuns`): the wall stands in rows for the days it is not drawing, so it
|
||
// is the shelf's own days it counts and not the window's. `dayHeads` is the
|
||
// month above it: a day the shelf's own month changes at opens with one, and
|
||
// the rest do not.
|
||
const { dayStarts, dayHeads } = useMemo(() => {
|
||
const starts: number[] = [];
|
||
const heads: number[] = [];
|
||
const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
|
||
for (let i = 0; i < listed.length; i++) {
|
||
if (i && dayKey(at(listed[i])) === dayKey(at(listed[i - 1]))) continue;
|
||
starts.push(i);
|
||
heads.push(i && monthKey(at(listed[i])) === monthKey(at(listed[i - 1])) ? 0 : 1);
|
||
}
|
||
return { dayStarts: starts, dayHeads: heads };
|
||
}, [listed]);
|
||
// A row is justified to the wall's own width at `ROW_TARGET`, which needs the
|
||
// shape of every frame in it — and the shape is the one thing the catalogue
|
||
// cannot be asked for: a scan reads a frame's date and its size off the file,
|
||
// not its width and its height, and decoding a whole roll to find out is a shelf
|
||
// that takes an age to stand up. The picture itself says, on the frame the
|
||
// browser hands it over (`onAr`), and the wall lays the shelf out again with
|
||
// what it has said; a frame still unread stands at the average rectangle of the
|
||
// ones that are not, which is as little as a row can be wrong by before its own
|
||
// pictures land.
|
||
const [arsAt, setArsAt] = useState(0);
|
||
const arRaf = useRef(0);
|
||
const bumpArs = useCallback(() => {
|
||
// One bump a frame, whatever a screenful of pictures says: a set of state per
|
||
// load is a render per load, and a row of twenty is twenty of them.
|
||
if (arRaf.current) return;
|
||
arRaf.current = requestAnimationFrame(() => {
|
||
arRaf.current = 0;
|
||
setArsAt((n) => n + 1);
|
||
});
|
||
}, []);
|
||
useEffect(() => () => cancelAnimationFrame(arRaf.current), []);
|
||
const onAr = useCallback(
|
||
(id: string, ar: number) => {
|
||
if (!(ar > 0)) return;
|
||
const held = wallAr.get(id);
|
||
if (held !== undefined && Math.abs(held - ar) < 0.005) return;
|
||
wallAr.set(id, ar);
|
||
bumpArs();
|
||
},
|
||
[bumpArs]
|
||
);
|
||
const ars = useMemo(() => {
|
||
let sum = 0;
|
||
for (const ar of wallAr.values()) sum += ar;
|
||
const fallback = wallAr.size ? sum / wallAr.size : WALL_AR;
|
||
// A frame Immich holds already said its own shape, so it is measured off
|
||
// nothing and stays where it was laid: `wallAr` is for the frames on the disk,
|
||
// which nobody has opened yet.
|
||
return listed.map((photo) => photo.ar ?? wallAr.get(photo.id) ?? fallback);
|
||
// `arsAt` is the shape map having grown: the numbers are read off it and not
|
||
// off the list, so nothing else here would make the shelf look again.
|
||
}, [listed, arsAt]);
|
||
// The width a row is laid in, and the shelf laid in it. `clientWidth` is the
|
||
// wall's own width and the scrollbar is not part of it, so a row justified to it
|
||
// meets the edge the reader sees.
|
||
const wallRef = useRef<HTMLDivElement>(null);
|
||
const wallWidth = useWallWidth(wallRef, view === 'grid');
|
||
const layout = useMemo(
|
||
() => wallLayout(dayStarts, dayHeads, ars, listed.length, wallWidth, WALL_STEPS),
|
||
[dayStarts, dayHeads, ars, listed.length, wallWidth]
|
||
);
|
||
const wall = useWallWindow(wallRef, layout.rows, 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. The window is cut into the rows it draws, and the frames of those
|
||
// rows are the slice the thumbnail cache is handed.
|
||
// The same window, cut into the rows it draws: a wall is read by the day a frame
|
||
// was shot, and the line above each run of them is that day. A row of the layout
|
||
// is already what the DOM draws — a run of frames of one day, or the day's own
|
||
// band — so the two are never two answers to the same question, and the row a
|
||
// window starts in is a row the layout knows the top of (see `wallLayout`).
|
||
const wallRows = useMemo(() => {
|
||
const rows: { row: WallRow; at: number; photos: LibraryPhoto[]; head: boolean }[] = [];
|
||
const last = Math.min(wall.to, layout.rows.length);
|
||
for (let i = Math.min(wall.from, last); i < last; i++) {
|
||
const row = layout.rows[i];
|
||
const first = listed[row.from];
|
||
rows.push({
|
||
row,
|
||
at: first ? first.taken || first.addedAt : 0,
|
||
photos: row.to > row.from ? listed.slice(row.from, row.to) : [],
|
||
head: row.band && dayHeads[row.day] === 1,
|
||
});
|
||
}
|
||
return rows;
|
||
}, [layout, listed, wall.from, wall.to, dayHeads]);
|
||
const painted = useMemo(() => {
|
||
if (!wallRows.length) return [];
|
||
const first = wallRows[0].row.from;
|
||
return listed.slice(first, Math.max(first, wallRows[wallRows.length - 1].row.to));
|
||
}, [wallRows, listed]);
|
||
// The timeline's own cut of the shelf: a tick a year, and the months of it
|
||
// under it, on the same three levels the wall is banded by. Read off the whole
|
||
// shelf and not the window — the ruler stands for the roll, not for the screen.
|
||
const timeMarks = useMemo(() => {
|
||
const marks: TimelineMark[] = [];
|
||
let year = '';
|
||
let month = '';
|
||
for (const photo of listed) {
|
||
const time = photo.taken || photo.addedAt;
|
||
const y = yearKey(time);
|
||
if (y !== year) {
|
||
year = y;
|
||
month = '';
|
||
marks.push({ kind: 'year', key: y, at: time, label: y });
|
||
}
|
||
const m = monthKey(time);
|
||
if (m !== month) {
|
||
month = m;
|
||
marks.push({ kind: 'month', key: m, at: time, label: monthLabel(time, lang) });
|
||
}
|
||
}
|
||
return marks;
|
||
}, [listed, lang]);
|
||
// The month the needle stands in: the row the eye is at, read off the shelf's
|
||
// own layout — the wall says which row the top of its viewport falls in — and
|
||
// not off the bands that happen to be drawn, which stand a couple of rows above
|
||
// the fold as well. `''` on an empty shelf, which leaves the needle at the top.
|
||
const eyePhoto = layout.rows.length ? listed[dayStarts[layout.rows[Math.min(wall.eye, layout.rows.length - 1)].day]] : undefined;
|
||
const eyeAt = eyePhoto ? eyePhoto.taken || eyePhoto.addedAt : 0;
|
||
const eyeMonth = eyeAt ? monthKey(eyeAt) : '';
|
||
// Going where the ruler is pressed: the first day of that month is brought to
|
||
// the top of the wall. The day's own top comes off the shelf's layout rather
|
||
// than off its box — the day is not in the DOM, which is the whole reason the
|
||
// layout exists (see `wallLayout`) — so the jump lands on a month the wall has
|
||
// never drawn.
|
||
const jumpToMonth = useCallback(
|
||
(at: number) => {
|
||
const el = wallRef.current;
|
||
if (!el || !listed.length) return;
|
||
const key = monthKey(at);
|
||
const yKey = yearKey(at);
|
||
let k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
|
||
if (k < 0) {
|
||
k = dayStarts.findIndex((start) => yearKey(listed[start].taken || listed[start].addedAt) === yKey);
|
||
}
|
||
if (k < 0) return;
|
||
const row = layout.rows[dayRow(layout.rows, k)];
|
||
if (!row) return;
|
||
thumbJob.current++;
|
||
el.scrollTop = Math.max(0, row.top - WALL_EYE);
|
||
},
|
||
[listed, dayStarts, layout, wallRef]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (wallRef.current) {
|
||
wallRef.current.scrollTop = 0;
|
||
}
|
||
}, [node]);
|
||
|
||
// 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 === null || !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 draws
|
||
// the rows around its viewport — so a pass with no card goes to the row the
|
||
// frame is drawn in, read off the shelf's own layout, and the pass after that
|
||
// reads the card itself. 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 = wallRef.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 if (layout.rows.length) {
|
||
const row = layout.rows[rowOf(layout.rows, idx)];
|
||
el.scrollTop = Math.max(0, Math.round(row.top - (el.clientHeight - row.height) / 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, layout, wallRef, 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.
|
||
// The frame the shelf is to open on after the studio, stamped so that coming
|
||
// back to the same frame a second time is still a landing (the phone's grid
|
||
// reads it — see MobileLibrary's `focus`).
|
||
const [landing, setLanding] = useState<{ id: string; at: number } | null>(null);
|
||
const wasHere = useRef(here);
|
||
useEffect(() => {
|
||
const came = here && !wasHere.current;
|
||
wasHere.current = here;
|
||
if (!came) return;
|
||
const last = recall(LAST_KEY);
|
||
if (!last) return;
|
||
setLanding({ id: last, at: Date.now() });
|
||
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 = wallRef.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>('.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');
|
||
};
|
||
|
||
// The frame next to this one, on the shelf's own order: the arrows at the
|
||
// edges of the stage and the swipe in the mobile preview both walk the list the
|
||
// reader is looking at, so what is drawn after the step is what was beside it.
|
||
const stepFrame = useCallback(
|
||
(d: number) => {
|
||
if (!frame) return;
|
||
const i = listed.findIndex((p) => p.id === frame.id);
|
||
if (i < 0) return;
|
||
const next = listed[i + d];
|
||
if (next) setUp(next.id);
|
||
},
|
||
[frame?.id, listed],
|
||
);
|
||
|
||
// 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);
|
||
// Where the open frame stands on the shelf, so the arrows at the stage's edges
|
||
// know which of them has nothing to go to.
|
||
const frameAt = listed.findIndex((p) => p.id === frame?.id);
|
||
|
||
|
||
|
||
// 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.
|
||
// A frame the wall has no tile for yet: a server's own frame is drawn straight
|
||
// from the address the proxy serves it at rather than left as an empty box, and
|
||
// the tile is minted and kept beside it (`useThumbUrls`), so what the reader
|
||
// sees first is the picture and what the visit after reads is this machine.
|
||
const tile = (photo: LibraryPhoto, onPick?: () => void, onAr?: (ar: number) => void) => {
|
||
const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null);
|
||
return src ? (
|
||
<TileImg
|
||
src={src}
|
||
rot={photo.rot ?? 0}
|
||
onPick={onPick}
|
||
onAr={onAr}
|
||
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>
|
||
);
|
||
};
|
||
|
||
if (isMobile) {
|
||
return (
|
||
<>
|
||
<MobileLibrary
|
||
folders={folders}
|
||
servers={servers}
|
||
serverYears={serverYears}
|
||
photos={photos}
|
||
dirs={dirs}
|
||
walked={walked}
|
||
edited={edited}
|
||
user={user}
|
||
admin={admin}
|
||
onOpenStudio={open}
|
||
focus={landing}
|
||
onAddImmich={() => setAddImmich(true)}
|
||
onEditAlbums={(folder) => setEditServer(folder.server ?? null)}
|
||
onSyncServer={syncServer}
|
||
onDisconnectServer={disconnect}
|
||
onFindImmich={findImmich}
|
||
onSetStar={async (photo, star) => {
|
||
await setStar(photo.id, star);
|
||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star } : p)));
|
||
}}
|
||
onRotate={async (photo, dir) => {
|
||
const rot = (((photo.rot || 0) + (dir === 'left' ? 270 : 90)) % 360) as 0 | 90 | 180 | 270;
|
||
await setRotation(photo.id, rot);
|
||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, rot } : p)));
|
||
}}
|
||
onSwitchToDesktop={() => setForceView('desktop')}
|
||
onFetchYear={fetchYearPhotos}
|
||
/>
|
||
{addImmich ? (
|
||
<ImmichDialog
|
||
servers={servers}
|
||
onEdit={(id) => {
|
||
setAddImmich(false);
|
||
setEditServer(id);
|
||
}}
|
||
onForget={(id) => void forgetLink(id)}
|
||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||
onClose={() => setAddImmich(false)}
|
||
/>
|
||
) : null}
|
||
{editServer ? (
|
||
<ImmichDialog
|
||
server={servers.find((s) => s.id === editServer) ?? null}
|
||
servers={servers}
|
||
onEdit={setEditServer}
|
||
onForget={(id) => {
|
||
setEditServer(null);
|
||
void forgetLink(id);
|
||
}}
|
||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||
onClose={() => setEditServer(null)}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
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" />
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
title="Xem giao diện Mobile Immich Cloud"
|
||
onClick={() => setForceView('mobile')}
|
||
style={{ gap: '4px' }}
|
||
>
|
||
📱 Mobile Cloud
|
||
</button>
|
||
<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)}
|
||
>
|
||
{servers.length ? t('lib.cloud.manage') : 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>
|
||
|
||
{/* 3b. Month of the year */}
|
||
<select
|
||
className="adm-input"
|
||
data-key="lib-filter-month"
|
||
aria-label={t('lib.month')}
|
||
value={month}
|
||
onChange={(e) => setMonth(e.target.value)}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<option value="all">{t('lib.monthAll')}</option>
|
||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||
<option key={m} value={String(m)}>
|
||
{String(m).padStart(2, '0')}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* 3c. Day of the month */}
|
||
<select
|
||
className="adm-input"
|
||
data-key="lib-filter-day"
|
||
aria-label={t('lib.day')}
|
||
value={day}
|
||
onChange={(e) => setDay(e.target.value)}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<option value="all">{t('lib.dayAll')}</option>
|
||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||
<option key={d} value={String(d)}>
|
||
{String(d).padStart(2, '0')}
|
||
</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>
|
||
|
||
{/* 5b. The way back to the whole roll: every filter in this
|
||
row undone at once, which is otherwise four selects and
|
||
two dropdowns put back by hand. The order is left alone —
|
||
a sort is not a filter, and it is where the reader was. */}
|
||
<button
|
||
type="button"
|
||
className="adm-input"
|
||
data-key="lib-filter-reset"
|
||
aria-label={t('lib.filterReset')}
|
||
title={t('lib.filterReset')}
|
||
onClick={() => {
|
||
setMinStar(0);
|
||
setYear('all');
|
||
setMonth('all');
|
||
setDay('all');
|
||
setFrom('any');
|
||
setTo('any');
|
||
}}
|
||
style={{ flexShrink: 0, cursor: 'pointer', fontWeight: 600, padding: '0 8px' }}
|
||
>
|
||
✕
|
||
</button>
|
||
|
||
{/* 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. The rows stand at their own
|
||
tops inside a body that holds the shelf's whole height —
|
||
a height that comes off the layout and not off the DOM —
|
||
so the shelf never moves under the reader, the frames at
|
||
the top and the bottom of the window are never stood
|
||
over, and the last row of the roll is a row the
|
||
scrollbar can reach.
|
||
A roll is read the way a contact sheet is: a day is a
|
||
line with the frames of that day under it, packed into
|
||
rows justified to the wall's own width, so the frames of
|
||
a day stand together, meet both edges, and keep their
|
||
own proportions. */}
|
||
<div className="lib-wall-row">
|
||
<div className="adm-grid" ref={wallRef}>
|
||
<div className="adm-wall-body" style={{ height: layout.height }}>
|
||
{wallRows.map(({ row, at, photos, head }) => (
|
||
<div
|
||
className={`adm-row${row.band ? ' adm-band' : ''}`}
|
||
key={row.band ? `h${row.day}` : `r${row.day}-${row.from}`}
|
||
data-key={row.band ? `lib-day-${dayKey(at)}` : undefined}
|
||
data-day={dayKey(at)}
|
||
style={{ top: row.top, height: row.height }}
|
||
>
|
||
{row.band ? (
|
||
<>
|
||
{/* The month above the first day it holds, and
|
||
the day over its frames: the month is
|
||
named once, and the day under it is the
|
||
line its frames are read by. */}
|
||
{head ? (
|
||
<h2 className="adm-month-head" data-key={`lib-month-${monthKey(at)}`}>
|
||
<span>{monthTitle(at, lang)}</span>
|
||
</h2>
|
||
) : null}
|
||
<h3 className="adm-day-head">
|
||
<i className="adm-day-dot" aria-hidden="true" />
|
||
<span>{dayLabel(at, lang)}</span>
|
||
</h3>
|
||
</>
|
||
) : (
|
||
photos.map((photo, j) => (
|
||
<article
|
||
className={`lib-tile${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||
key={photo.id}
|
||
data-key={`lib-photo-${photo.id}`}
|
||
style={{ width: ars[row.from + j] * row.height }}
|
||
title={`${photo.name} · ${new Date(photo.taken || photo.addedAt).toLocaleDateString()} · ${t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}`}
|
||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||
onDoubleClick={() => {
|
||
setUp(photo.id);
|
||
scrolledFrameRef.current = null;
|
||
setView('preview');
|
||
}}
|
||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||
>
|
||
{tile(photo, undefined, (ar) => {
|
||
// Only a frame whose shape is not known
|
||
// already is worth laying the shelf out
|
||
// again for.
|
||
if (photo.ar === undefined) onAr(photo.id, ar);
|
||
})}
|
||
{/* The score over 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. It stands on the frame the pointer
|
||
is on, and stays on a frame that has a
|
||
score. The click stops at the star — a
|
||
score is not a pick, and the tile's own
|
||
click is what selects the frame. */}
|
||
<div
|
||
className={`lib-stars${(photo.star ?? 0) > 0 ? ' on' : ''}`}
|
||
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>
|
||
</article>
|
||
))
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* The shelf's own time, down its right edge: a year is the
|
||
tick that is named, the months of it are the ticks under
|
||
it, and the band the eye is in is the needle. */}
|
||
<TimelineRuler
|
||
marks={timeMarks}
|
||
value={eyeAt}
|
||
readout={eyeMonth ? `${monthLabel(eyeAt, lang)} ${yearKey(eyeAt)}` : ''}
|
||
onPick={(mark) => jumpToMonth(mark.at)}
|
||
/>
|
||
</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',
|
||
}}
|
||
>
|
||
{/* The frame's neighbours, at the edges the eye already
|
||
reads as "there is more this way". Both are drawn
|
||
whenever the shelf holds another frame, and the one
|
||
with nothing to go to is dimmed rather than taken
|
||
away, so the pair does not jump about. They are the
|
||
stage's own overlay: a press on one must not reach
|
||
the zoom, the pan or the open under it. */}
|
||
{listed.length > 1 && frameAt >= 0 ? (
|
||
<>
|
||
{(
|
||
[
|
||
{ d: -1, key: 'lib-prev', edge: { left: '10px' }, label: t('lib.prev'), icon: 'M10 2 4 8l6 6' },
|
||
{ d: 1, key: 'lib-next', edge: { right: '10px' }, label: t('lib.next'), icon: 'M6 2l6 6-6 6' },
|
||
] as const
|
||
).map((a) => {
|
||
const at = frameAt + a.d;
|
||
const off = at < 0 || at >= listed.length;
|
||
return (
|
||
<button
|
||
key={a.key}
|
||
type="button"
|
||
className="lib-stage-nav"
|
||
data-key={a.key}
|
||
disabled={off}
|
||
aria-label={a.label}
|
||
title={a.label}
|
||
style={{ ...a.edge, opacity: off ? 0.25 : 1, cursor: off ? 'default' : 'pointer' }}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onPointerMove={(e) => e.stopPropagation()}
|
||
onPointerUp={(e) => e.stopPropagation()}
|
||
onDoubleClick={(e) => e.stopPropagation()}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
stepFrame(a.d);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d={a.icon} />
|
||
</svg>
|
||
</button>
|
||
);
|
||
})}
|
||
</>
|
||
) : null}
|
||
{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={() => {
|
||
setEditServer(menu.folder!.server ?? null);
|
||
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
|
||
servers={servers}
|
||
onEdit={(id) => {
|
||
setAddImmich(false);
|
||
setEditServer(id);
|
||
}}
|
||
onForget={(id) => void forgetLink(id)}
|
||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||
onClose={() => setAddImmich(false)}
|
||
/>
|
||
) : null}
|
||
{editServer ? (
|
||
<ImmichDialog
|
||
server={servers.find((s) => s.id === editServer) ?? null}
|
||
servers={servers}
|
||
onEdit={setEditServer}
|
||
onForget={(id) => {
|
||
setEditServer(null);
|
||
void forgetLink(id);
|
||
}}
|
||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||
onClose={() => setEditServer(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.
|
||
// The picture is also the one thing that knows how wide a frame is against its
|
||
// own height, which is what a justified row is laid out with: `onAr` is handed
|
||
// the ratio on the frame the browser decodes it, and the wall lays the shelf out
|
||
// again with it (`onAr` in the catalogue).
|
||
function TileImg({
|
||
src,
|
||
rot,
|
||
onPick,
|
||
onAr,
|
||
title,
|
||
}: {
|
||
src: string;
|
||
rot: number;
|
||
onPick?: () => void;
|
||
onAr?: (ar: number) => void;
|
||
title?: string;
|
||
}) {
|
||
return (
|
||
<img
|
||
src={useTurned(src, rot) ?? src}
|
||
alt=""
|
||
loading="lazy"
|
||
onClick={onPick}
|
||
title={title}
|
||
onLoad={
|
||
onAr
|
||
? (e) => {
|
||
const img = e.currentTarget;
|
||
if (img.naturalWidth > 0 && img.naturalHeight > 0) onAr(img.naturalWidth / img.naturalHeight);
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// The shape of the frames the wall has been shown, by the frame's own id, for the
|
||
// length of the visit: the catalogue cannot be asked for one — a scan reads a date
|
||
// and a size off a file, not a width and a height — and a row is justified to the
|
||
// wall's width, which is a sum of shapes. Kept past the row that measured it, so a
|
||
// frame scrolled out of the wall and back is not measured again.
|
||
const wallAr = new Map<string, number>();
|
||
|
||
// 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 = 2000;
|
||
|
||
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);
|
||
}
|
||
|
||
export function getSyncThumbUrl(photo: LibraryPhoto): string | null {
|
||
const held = globalThumbUrls.get(photo.id);
|
||
if (held) return held.url;
|
||
const blob = photo.thumb ?? getCachedThumb(photo.id);
|
||
if (blob) {
|
||
return cacheObjectUrl(photo.id, blob);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export 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]);
|
||
|
||
// Synchronously resolve any thumb URL already available in memory to eliminate render flicker/flashing
|
||
const syncUrls = useMemo(() => {
|
||
const res: Record<string, string> = {};
|
||
for (const p of list) {
|
||
const u = getSyncThumbUrl(p);
|
||
if (u) res[p.id] = u;
|
||
}
|
||
return res;
|
||
}, [list, stamp]);
|
||
|
||
const [asyncUrls, setAsyncUrls] = useState<Record<string, string>>({});
|
||
|
||
const urls = useMemo(() => {
|
||
return { ...asyncUrls, ...syncUrls };
|
||
}, [asyncUrls, syncUrls]);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
if (!list.length) {
|
||
setAsyncUrls({});
|
||
return;
|
||
}
|
||
|
||
const idsToFetch: string[] = [];
|
||
for (const p of list) {
|
||
if (!globalThumbUrls.has(p.id) && !p.thumb && !getCachedThumb(p.id)) {
|
||
idsToFetch.push(p.id);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
setAsyncUrls((prev) => ({ ...prev, ...landed }));
|
||
}
|
||
|
||
// A server's own frame is minted here as well: its tile is the one Immich
|
||
// drew, asked for once and kept in the store, so the day the reader jumps
|
||
// back to that year the wall draws it out of this machine instead of
|
||
// paying the round trip again — the wall is read up and down the roll, and
|
||
// every jump is otherwise the same frames asked of the server a second time.
|
||
const queue = list.filter((p) => !(p.id in landed) && !globalThumbUrls.has(p.id));
|
||
let batchUrls: Record<string, string> = {};
|
||
let flushTimer: any = null;
|
||
const flush = () => {
|
||
if (!alive || Object.keys(batchUrls).length === 0) return;
|
||
const cur = batchUrls;
|
||
batchUrls = {};
|
||
setAsyncUrls((prev) => ({ ...prev, ...cur }));
|
||
};
|
||
|
||
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) {
|
||
batchUrls[photo.id] = cacheObjectUrl(photo.id, blob);
|
||
if (Object.keys(batchUrls).length >= 4) {
|
||
flush();
|
||
} else if (!flushTimer) {
|
||
flushTimer = setTimeout(() => {
|
||
flushTimer = null;
|
||
flush();
|
||
}, 40);
|
||
}
|
||
}
|
||
await breathe();
|
||
}
|
||
})
|
||
);
|
||
if (flushTimer) clearTimeout(flushTimer);
|
||
flush();
|
||
});
|
||
|
||
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 = () => {
|
||
// The window is measured off the strip that is up, not off the one this
|
||
// effect was set up on: the stage is drawn while a node is open, so a visit
|
||
// that loses its nodes and gets them back draws a new strip, and a window
|
||
// measured off the old node — a box of nothing, a scroll always nought — is
|
||
// a window that never moves again for the rest of the visit.
|
||
const now = ref.current;
|
||
if (!now) return;
|
||
const from = Math.max(0, Math.floor(now.scrollLeft / TILE_STEP) - STRIP_KEEP);
|
||
const to = Math.min(count, Math.ceil((now.scrollLeft + now.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 };
|
||
}
|
||
|
||
|
||
// The width a row of the wall is laid in: the wall's own `clientWidth`, which is
|
||
// the box inside the scrollbar and not the one past it, so a row justified to it
|
||
// meets the edge the reader sees. Measured and not assumed — the pane is resized
|
||
// by the window and by the tree beside it, and a shelf laid in a width it no
|
||
// longer has is a row that runs past the wall or stops short of it.
|
||
function useWallWidth(ref: RefObject<HTMLDivElement>, on: boolean): number {
|
||
const [width, setWidth] = useState(0);
|
||
// Read off the wall that is up and not off the one this was set up on: the wall
|
||
// is what the desk draws, and the phone's shelf stands in its place, so a visit
|
||
// that opens on the phone mounts it with no wall to measure — and the width left
|
||
// at nought lays every row of the shelf out one pixel wide, which is a wall of
|
||
// nothing until something else makes the effect below run again. So it is read
|
||
// after every render, the way the window the wall draws is (see `useWallWindow`).
|
||
const read = useCallback(() => {
|
||
const el = ref.current;
|
||
if (!el || !el.clientWidth) return;
|
||
setWidth((prev) => (prev === el.clientWidth ? prev : el.clientWidth));
|
||
}, [ref]);
|
||
useLayoutEffect(() => {
|
||
read();
|
||
});
|
||
useLayoutEffect(() => {
|
||
if (!on) return;
|
||
read();
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const ro = new ResizeObserver(read);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [ref, on, read]);
|
||
return width;
|
||
}
|
||
|
||
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
|
||
// either side. The rows not drawn are not in the DOM at all — the shelf's own
|
||
// height stands in the body above them, off the layout (`wallLayout`), so the
|
||
// scrollbar still spans the roll — 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.
|
||
//
|
||
// The window is reported in rows and not as a slice of the shelf, because a row is
|
||
// what the wall draws and the row a scroll position falls in is a question the
|
||
// layout answers (`rowAt`). `eye` is that same row at the top of the viewport,
|
||
// which is the row the ruler's needle reads off.
|
||
function useWallWindow(
|
||
ref: RefObject<HTMLDivElement>,
|
||
rows: WallRow[],
|
||
on: boolean
|
||
): { from: number; to: number; eye: number } {
|
||
const [win, setWin] = useState({ from: 0, to: 0 });
|
||
const [eye, setEye] = useState(0);
|
||
// What was last written, because the reader of a `setWin` in a layout effect is
|
||
// not the state: a set made from one renders again whether or not the value
|
||
// moved, and this one is measured after every render — so a set it did not need
|
||
// is the screen's own render loop (React #185, "Maximum update depth exceeded").
|
||
// The refs are the guard: nothing is set that the wall already stands at.
|
||
const winRef = useRef(win);
|
||
const eyeRef = useRef(0);
|
||
const measureRef = useRef<() => void>(() => {});
|
||
useLayoutEffect(() => {
|
||
if (!on) return;
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const measure = () => {
|
||
// The wall that is up, and not the one this effect was set up on: the stage
|
||
// is drawn while a node is open, so a visit that loses its nodes and gets
|
||
// them back draws a new wall, and a window measured off the box it replaced
|
||
// is a window read off nothing.
|
||
const now = ref.current;
|
||
if (!now) return;
|
||
// A wall that is not standing has no box: the library is `display: none`
|
||
// while the studio is up, and every scroll read off it is nought — which
|
||
// threw the shelf back to its top every time the studio handed back to it.
|
||
// It is read again on the frame it comes back up, which is a box change.
|
||
if (!now.clientHeight) return;
|
||
if (!rows.length) {
|
||
if (winRef.current.from || winRef.current.to) {
|
||
winRef.current = { from: 0, to: 0 };
|
||
setWin(winRef.current);
|
||
}
|
||
return;
|
||
}
|
||
// A window that is nowhere on the shelf — a shorter roll under it, or the
|
||
// first paint, before the catalogue has landed — stands nowhere: it opens at
|
||
// the top. Written off the shelf's own rows and not off the window before it,
|
||
// which is what made the reset a window the window fed.
|
||
if (winRef.current.from >= rows.length || winRef.current.to > rows.length) {
|
||
winRef.current = { from: 0, to: 0 };
|
||
setWin(winRef.current);
|
||
}
|
||
const from = Math.max(0, rowAt(rows, now.scrollTop) - WALL_ROWS);
|
||
const to = Math.min(rows.length, rowAt(rows, now.scrollTop + now.clientHeight) + 1 + WALL_ROWS);
|
||
if (winRef.current.from !== from || winRef.current.to !== to) {
|
||
winRef.current = { from, to };
|
||
setWin({ from, to });
|
||
}
|
||
const at = Math.max(0, Math.min(rows.length - 1, rowAt(rows, now.scrollTop + WALL_EYE)));
|
||
if (eyeRef.current !== at) {
|
||
eyeRef.current = at;
|
||
setEye(at);
|
||
}
|
||
};
|
||
measureRef.current = measure;
|
||
// Read here as well as after every render: this runs before the first paint,
|
||
// so the wall opens on the rows the eye is in and not on a screen of nothing.
|
||
measure();
|
||
el.addEventListener('scroll', measure, { passive: true });
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
return () => {
|
||
measureRef.current = () => {};
|
||
el.removeEventListener('scroll', measure);
|
||
ro.disconnect();
|
||
};
|
||
}, [rows, ref, on]);
|
||
useLayoutEffect(() => {
|
||
// And again after every render: the shelf is laid out again when the wall is
|
||
// resized, when a frame's shape lands, and when the roll itself changes, and
|
||
// none of the three is a thing this effect can see coming. What is set is only
|
||
// what the wall is not already at (above).
|
||
measureRef.current();
|
||
});
|
||
return { from: win.from, to: win.to, eye };
|
||
}
|
||
|
||
|
||
// One tick of the wall's own time: a year, or a month of one. Built off the
|
||
// shelf and not off a calendar — a month the roll holds nothing in is a month
|
||
// with no tick, which is what keeps the ruler the length of the roll.
|
||
type TimelineMark = { kind: 'year' | 'month'; key: string; at: number; label: string };
|
||
|
||
// The wall's time, read the way the studio reads a number: a graduated scale
|
||
// down the shelf's right edge, the year the tick that is named and the months
|
||
// of it the ticks under it, with the band the eye is in carrying the needle.
|
||
// Dragging anywhere on the scale takes the wall to the month under the pointer,
|
||
// the wheel goes one month a notch, and a press on a tick goes to that tick: the
|
||
// scale is read by index, so every month of a roll of any length is a press away,
|
||
// and a month nothing was shot in is not a month the reader can be taken to.
|
||
function TimelineRuler({
|
||
marks,
|
||
value,
|
||
readout,
|
||
onPick,
|
||
}: {
|
||
marks: TimelineMark[];
|
||
value: number;
|
||
readout: string;
|
||
onPick: (mark: TimelineMark) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const scaleRef = useRef<HTMLDivElement>(null);
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const dragging = useRef(false);
|
||
const live = useRef({ marks, onPick, value });
|
||
live.current = { marks, onPick, value };
|
||
|
||
// Where the needle stands: the month of the band the eye is under, looked up
|
||
// by the date the wall handed over — the shelf's own month, so a wall standing
|
||
// between two months is read as the one whose frames are over the eye. A value
|
||
// that names no month of the shelf (an empty one) leaves the needle at the top.
|
||
const eyeOf = (marks: TimelineMark[], value: number) => {
|
||
let i = marks.findIndex((mark) => mark.kind === 'month' && mark.at === value);
|
||
if (i < 0) {
|
||
for (let k = marks.length - 1; k >= 0; k--) {
|
||
if (marks[k].kind === 'month' && marks[k].at >= value) {
|
||
i = k;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return i;
|
||
};
|
||
const eye = eyeOf(marks, value);
|
||
const last = Math.max(1, marks.length - 1);
|
||
const pctOf = (i: number) => (i / last) * 100;
|
||
|
||
// The wheel belongs to the ruler while the pointer is on it: one notch is one
|
||
// month, whichever way the roll is read. Native, because React's own listener
|
||
// is passive and the notch must not reach the wall under it.
|
||
useEffect(() => {
|
||
const el = rootRef.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const s = live.current;
|
||
const from = eyeOf(s.marks, s.value);
|
||
if (from < 0) return;
|
||
const dir = e.deltaY < 0 ? -1 : 1;
|
||
for (let k = from + dir; k >= 0 && k < s.marks.length; k += dir) {
|
||
if (s.marks[k].kind === 'month') {
|
||
s.onPick(s.marks[k]);
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => el.removeEventListener('wheel', onWheel);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// A pixel of travel is well under a month on any shelf worth a ruler, so the
|
||
// reading snaps to the month the pointer is nearest however far it is dragged.
|
||
const markAt = (clientY: number) => {
|
||
const r = scaleRef.current?.getBoundingClientRect();
|
||
if (!r || !(r.height > 0) || !marks.length) return -1;
|
||
const frac = (clientY - r.top) / r.height;
|
||
return Math.max(0, Math.min(marks.length - 1, Math.round(frac * last)));
|
||
};
|
||
|
||
const lastPickRef = useRef<number>(-1);
|
||
|
||
return (
|
||
<div ref={rootRef} className="lib-ruler" data-key="lib-ruler" onPointerDown={(e) => e.stopPropagation()}>
|
||
<div className="lib-ruler-head">
|
||
<span className="lib-ruler-name">{t('lib.rail')}</span>
|
||
<b className="lib-ruler-value" data-key="lib-ruler-value">
|
||
{readout}
|
||
</b>
|
||
</div>
|
||
<div
|
||
ref={scaleRef}
|
||
className="lib-ruler-scale"
|
||
data-key="lib-ruler-scale"
|
||
onPointerDown={(e) => {
|
||
dragging.current = true;
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
const i = markAt(e.clientY);
|
||
if (i >= 0 && i !== lastPickRef.current) {
|
||
lastPickRef.current = i;
|
||
onPick(marks[i]);
|
||
}
|
||
}}
|
||
onPointerMove={(e) => {
|
||
if (!dragging.current) return;
|
||
const i = markAt(e.clientY);
|
||
if (i >= 0 && i !== lastPickRef.current) {
|
||
lastPickRef.current = i;
|
||
onPick(marks[i]);
|
||
}
|
||
}}
|
||
onPointerUp={(e) => {
|
||
dragging.current = false;
|
||
lastPickRef.current = -1;
|
||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||
}}
|
||
onPointerCancel={() => {
|
||
dragging.current = false;
|
||
lastPickRef.current = -1;
|
||
}}
|
||
>
|
||
{/* The year's own band: the months of one year stand together under it,
|
||
which is the whole of what "read by the year, cut by the month" is. */}
|
||
{marks.map((mark, i) =>
|
||
mark.kind !== 'year' ? null : (
|
||
<span
|
||
key={`band-${mark.key}`}
|
||
className="lib-ruler-band"
|
||
aria-hidden="true"
|
||
style={{ top: `${pctOf(i)}%`, height: `${pctOf(yearEnd(marks, i)) - pctOf(i)}%` }}
|
||
/>
|
||
)
|
||
)}
|
||
{marks.map((mark, i) => (
|
||
<span
|
||
key={mark.key}
|
||
className={`lib-ruler-tick lib-ruler-${mark.kind}${i === eye ? ' on' : ''}`}
|
||
style={{ top: `${pctOf(i)}%` }}
|
||
data-key={`lib-ruler-${mark.key}`}
|
||
data-kind={mark.kind}
|
||
data-at={mark.at}
|
||
title={mark.label}
|
||
onPointerDown={(e) => {
|
||
e.stopPropagation();
|
||
onPick(mark);
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onPick(mark);
|
||
}}
|
||
>
|
||
{mark.kind === 'year' ? <i>{mark.label}</i> : null}
|
||
</span>
|
||
))}
|
||
<span
|
||
className="lib-ruler-needle"
|
||
data-key="lib-ruler-needle"
|
||
style={{ top: `${eye < 0 ? 0 : pctOf(eye)}%` }}
|
||
aria-hidden="true"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// The mark the year at `i` ends at — the next year's own tick, or the last mark
|
||
// of the ruler — so a year's band stands under every month of it and no more.
|
||
function yearEnd(marks: TimelineMark[], i: number): number {
|
||
for (let k = i + 1; k < marks.length; k++) {
|
||
if (marks[k].kind === 'year') return k;
|
||
}
|
||
return marks.length - 1;
|
||
}
|