From 3befdf50eb29c0c4fc835db531d1920012fa8f37 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 10 Oct 2026 18:27:49 +0700 Subject: [PATCH] fix(library): a reading that never finished no longer says nought MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `saveWalkedDirs` filled in a nought beside every folder a reading had listed but never opened. A reading stopped by a closed tab, and one kept to a branch, list the rest of the roll before they have been into any of it — 484 folders of one real roll read empty over tens of thousands of frames — and the record is folded back into itself, so the noughts stayed. Only a reading that met the whole roll may say nought now (`WalkedDirs.full`), and the screen drops a nought from a record that does not say so. An old record has no word to give, so its rows fall back on the catalogue instead. feat(library): search Immich from the filter row One box, one query, one node: the backend reads what is typed as a tag name when the key can see one and as a file name otherwise, and the answer is filed under the node itself. A share link searches its one album in place. The box is not a filter — it leaves the shelf, which is what the md's search permission is for. feat(ui): a shorter status line, and a wall that keeps its own row height The status line is one short 12px row at the foot of the library. A wall with fewer frames than there are columns no longer stretches its one row — and the cards' own borders with it — down to the stage. --- docker/backend/src/immich.ts | 40 +++++++++++++- docker/backend/test/immich.mjs | 41 ++++++++++++++- docker/frontend/src/Library.tsx | 66 ++++++++++++++++++++++- docker/frontend/src/engine/immich.ts | 75 +++++++++++++++++++++++---- docker/frontend/src/engine/library.ts | 60 +++++++++++++++------ docker/frontend/src/i18n/en.ts | 4 ++ docker/frontend/src/i18n/vi.ts | 4 ++ docker/frontend/src/styles/app.css | 11 ++-- 8 files changed, 267 insertions(+), 34 deletions(-) diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts index 7d84a9b..9395f8f 100644 --- a/docker/backend/src/immich.ts +++ b/docker/backend/src/immich.ts @@ -19,6 +19,9 @@ const MAX_KEY = 400; const MAX_NAME = 60; const MAX_ALBUMS = 200; const MAX_PAGE_SIZE = 200; +// What the shelf's search box may carry. Bounded because it goes on to another +// box as a query, and a search box is a thing people paste into. +const MAX_QUERY = 100; // A server is a box the operator may not control, and a hang there must not // become a hung LIBRARY. Streaming an original is the one call that legitimately @@ -201,6 +204,18 @@ async function listAlbums(server: ImmichServer): Promise { return ((await jsonOf(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album)); } +// The tags this key can see whose name holds what was typed. Asked for on every +// search rather than cached: a tag made on Immich has to be found by the next +// one, and the list is small. A key without `tag.read` is answered 403, which is +// a key that cannot search by tag — not a search that failed. +async function tagIdsMatching(server: ImmichServer, q: string): Promise { + const res = await call(server, { path: '/tags' }); + if (!res.ok) return []; + const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? []; + const needle = q.toLowerCase(); + return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string); +} + async function firstAssetId(server: ImmichServer): Promise { if (server.type === 'share') { const res = await call(server, { path: '/shared-links/me' }); @@ -389,12 +404,19 @@ export function immichRoutes( const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100)); const album = q.album && q.album !== 'all' ? q.album : null; if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' }); + // What the shelf's search box carries: words to look for in a file's own name, + // or the name of a tag. A share link has no search of its own, so its one + // album is sifted here instead. + const search = (q.q ?? '').trim().slice(0, MAX_QUERY); if (server.type === 'share') { const res = await call(server, { path: '/shared-links/me' }); if (!res.ok) return reply.status(502).send(refusal(res)); const me = await jsonOf<{ assets?: RawAsset[] }>(res); - const all = (me?.assets ?? []).filter((a) => a.type === 'IMAGE' || a.type === undefined); + const needle = search.toLowerCase(); + const all = (me?.assets ?? []) + .filter((a) => a.type === 'IMAGE' || a.type === undefined) + .filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle)); const start = (page - 1) * size; return reply.send({ ok: true, @@ -406,10 +428,24 @@ export function immichRoutes( }); } + // One query, one answer. Immich reads every field it is handed as one more + // thing an asset must be — `originalFileName` with `tagIds` is a file called + // this *and* tagged that — so the box is read as a tag when it names one, and + // as a file name otherwise, which keeps the page a page of one list. + // ponytail: a query that names a tag finds only that tag's frames; fold the + // two answers into one list if a reader ever needs both at once. + const tagIds = search ? await tagIdsMatching(server, search) : []; const res = await call(server, { method: 'POST', path: '/search/metadata', - json: { size, page, withExif: true, type: 'IMAGE', ...(album ? { albumIds: [album] } : {}) }, + json: { + size, + page, + withExif: true, + type: 'IMAGE', + ...(album ? { albumIds: [album] } : {}), + ...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}), + }, }); if (!res.ok) return reply.status(502).send(refusal(res)); const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res); diff --git a/docker/backend/test/immich.mjs b/docker/backend/test/immich.mjs index 809b704..7bda80f 100644 --- a/docker/backend/test/immich.mjs +++ b/docker/backend/test/immich.mjs @@ -26,10 +26,14 @@ const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without ass const SHARE_KEY = 'share-key-1234567890'; const ALBUM_A = '11111111-1111-4111-8111-111111111111'; const ALBUM_B = '22222222-2222-4222-8222-222222222222'; +const TAG_HOME = '33333333-3333-4333-8333-333333333333'; +const TAG_TRIP = '44444444-4444-4444-8444-444444444444'; const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes'); const ORIGINAL = Buffer.from('II*\0fake-original-bytes'); +// The first frame is the one tagged `Nhà`, so a search for that word has exactly +// one answer to find. const asset = (n, album) => ({ id: assetId(n), type: 'IMAGE', @@ -39,6 +43,7 @@ const asset = (n, album) => ({ height: 3000, exifInfo: { fileSizeInByte: 1000 * n }, album, + tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null, }); let pass = 0; @@ -103,9 +108,27 @@ function startFake() { ]); } + // A tag list, and the one key that may not read it: searching by tag is the + // one search a narrow key loses, and it has to lose it as an empty list + // rather than as a failed search. + if (url.pathname === '/api/tags') { + if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' }); + if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: tag.read' }); + return send(200, [ + { id: TAG_HOME, name: 'Nhà', value: 'nhà' }, + { id: TAG_TRIP, name: 'du lịch', value: 'du lịch' }, + ]); + } + if (url.pathname === '/api/search/metadata') { const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)]; - const filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all; + let filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all; + // The same reading the proxy makes of a search box: every field given is + // one more thing an asset must be. + if (Array.isArray(body.tagIds)) filtered = filtered.filter((a) => body.tagIds.includes(a.tags)); + if (typeof body.originalFileName === 'string') { + filtered = filtered.filter((a) => a.originalFileName.includes(body.originalFileName)); + } const size = Number(body.size ?? 100); const page = Number(body.page ?? 1); const items = filtered.slice((page - 1) * size, page * size); @@ -301,6 +324,16 @@ try { const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`); check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`); + // ---- the search box ------------------------------------------------------ + const byName = await user.req(`/immich/assets?server=${serverId}&q=photo-2`); + check('a search reads the words as a file name', byName.body?.items?.length === 1 && byName.body?.items?.[0]?.name === 'photo-2.jpg', JSON.stringify(byName.body?.items)); + const byTag = await user.req(`/immich/assets?server=${serverId}&q=${encodeURIComponent('Nhà')}`); + check('a search that names a tag finds that tag', byTag.body?.items?.length === 1 && byTag.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(byTag.body?.items)); + check('the tag ids it found travel as tag ids', seen.at(-1)?.path === '/api/search/metadata', JSON.stringify(seen.at(-1))); + const noAnswer = await user.req(`/immich/assets?server=${serverId}&q=nothing-at-all`); + check('a search that answers nothing is an empty page, not a failure', noAnswer.status === 200 && noAnswer.body?.items?.length === 0 && noAnswer.body?.hasMore === false, JSON.stringify(noAnswer.body)); + + // ---- the pixels --------------------------------------------------------- const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`); check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`); @@ -329,6 +362,10 @@ try { check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body)); const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`); check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body)); + // The same narrow key searching by a tag name it cannot read: the 403 on the + // tag list is an empty list of tags, not a search that came back broken. + const narrowSearch = await user.req(`/immich/assets?server=${readerId}&q=${encodeURIComponent('Nhà')}`); + check('a key without tag.read still searches by file name', narrowSearch.status === 200 && narrowSearch.body?.items?.length === 0, JSON.stringify(narrowSearch.body)); // ---- a share link is one album ------------------------------------------ const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' }); @@ -339,6 +376,8 @@ try { check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body)); const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`); check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body)); + const shareSearch = await user.req(`/immich/assets?server=${shareId}&q=photo-5`); + check('a share link searches its one album', shareSearch.body?.items?.length === 1 && shareSearch.body?.items?.[0]?.name === 'photo-5.jpg', JSON.stringify(shareSearch.body?.items)); const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`); check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`); check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1))); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index febb033..7c4953c 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -65,6 +65,7 @@ import { forgetImmichServer, immichConfig, saveImmichAlbums, + searchImmichFolder, syncImmich, type ImmichServer, } from './engine/immich'; @@ -426,6 +427,11 @@ function Catalogue({ admin }: { admin: boolean }) { const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all'); const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any'); const [to, setTo] = useState(() => recall(TO_KEY) || 'any'); + // What the shelf's search box holds while it is being typed in. It is not a + // filter: a filter looks through the frames already here, and the search asks + // Immich for frames that are not here at all. Nothing is kept between visits — + // the search node it makes outlives the box. + const [search, setSearch] = useState(''); useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]); useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]); @@ -498,7 +504,15 @@ function Catalogue({ admin }: { admin: boolean }) { // spellings of every key. const counts: Record = {}; for (const row of walkedRows) { - for (const [key, n] of Object.entries(row.counts)) counts[key.toLowerCase()] = n; + 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 @@ -1204,6 +1218,32 @@ function Catalogue({ admin }: { admin: boolean }) { [editAlbums, syncServer] ); + // The shelf's search box, run. What is typed is a name or a tag, and Immich is + // the one that reads it: a disk's folders are already on the column, and a roll + // is read by walking it rather than by calling it names. The server asked is the + // one the open node belongs to, or the first the account saved. The answer + // becomes a node of its own, because a search is a shelf the reader may come + // back to — `engine/immich.ts` files the frames under the node. + const findImmich = useCallback( + async (query: string) => { + const q = query.trim(); + if (!q) return; + const open = folders.find((f) => f.name === node)?.server; + const server = servers.find((s) => s.id === open) ?? servers[0] ?? null; + if (!server) { + setNote(t('lib.search.noServer')); + return; + } + const current = await listFolders(); + const folder = await searchImmichFolder(server, q, current); + setFolders([...current.filter((f) => f.name !== folder.name), folder].sort(bySourceThenName)); + setReady((prev) => new Set(prev).add(folder.name)); + setNode(folder.name); + await syncServer(folder); + }, + [folders, node, servers, syncServer, t] + ); + // Ngắt kết nối: the key goes and the node goes with it — the address and the // key are the account's, and a node with neither to read by is a row that could // only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and @@ -2573,7 +2613,29 @@ function Catalogue({ admin }: { admin: boolean }) { ))} - {/* 6. Count */} + {/* 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. */} + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + void findImmich(search); + }} + style={{ flexShrink: 0 }} + /> + + {/* 7. Count */} {t('lib.count', { n: listed.length })} diff --git a/docker/frontend/src/engine/immich.ts b/docker/frontend/src/engine/immich.ts index 2cb9d4e..ec14c7f 100644 --- a/docker/frontend/src/engine/immich.ts +++ b/docker/frontend/src/engine/immich.ts @@ -183,10 +183,12 @@ export async function saveImmichAlbums(server: string, selected: string[]): Prom async function listAssets( server: string, album: string | null, - page: number + page: number, + search = '' ): Promise<{ items: ImmichAsset[]; hasMore: boolean }> { const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) }); if (album) query.set('album', album); + if (search) query.set('q', search); const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`); return { items: body?.items ?? [], hasMore: body?.hasMore === true }; } @@ -236,12 +238,20 @@ export interface ImmichSyncResult { // fresh while the others are a week old is a column nobody can read a number off. export async function syncImmich(folder: LibraryFolder): Promise { const server = folder.server ?? ''; - const albums = await listImmichAlbums(server); + // A search node is one query and no albums: the backend reads it as a name or a + // tag, and what it answers with is filed under the node itself — a label of `''` + // is the node's own wall — so the album list is not even asked for. + const search = folder.kind === 'search' ? (folder.query ?? '').trim() : ''; + const albums = search ? [] : await listImmichAlbums(server); const labels = albumLabels(albums); const chosen = folder.albums ?? []; // An empty tick list is a decision and not a missing value: it means every album // this key can see (see the plan, the album-ID field). - const wanted = albums.filter((a) => chosen.length === 0 || chosen.includes(a.id)); + const wanted: { id: string | null; label: string }[] = search + ? [{ id: null, label: '' }] + : albums + .filter((a) => chosen.length === 0 || chosen.includes(a.id)) + .map((a) => ({ id: a.id, label: labels.get(a.id) ?? a.id.slice(0, 8) })); const rows: LibraryPhoto[] = []; const dirs: LibraryDir[] = []; @@ -249,20 +259,22 @@ export async function syncImmich(folder: LibraryFolder): Promise(); let page = 1; let count = 0; for (;;) { - const { items, hasMore } = await listAssets(server, album.id, page); + const { items, hasMore } = await listAssets(server, album.id, page, search); for (const item of items) { if (!item.id || !item.name) continue; const name = frameName(item.name, item.id, taken); taken.add(item.name); rows.push({ - id: `${root}/${label}/${name}`, + // A search node's frames hang off the node itself, so their path is one + // segment shorter than an album's. + id: [root, label, name].filter(Boolean).join('/'), folder: folder.name, dir: label, name, @@ -277,8 +289,12 @@ export async function syncImmich(folder: LibraryFolder): Promise `${normPath(r.dir)}/${r.name}`.toLowerCase())); + // Spelled the way `prunePhotos` reads a row back: no leading slash for a frame + // that hangs off the node itself. + const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase())); await prunePhotos(root, '', met); await putPhotos(rows); @@ -331,6 +349,45 @@ export function immichFolderLabel(serverName: string): string { // name, the server it reads, and the albums it keeps — and `taken` is what it must // not collide with, because the name is the store's key and two keys cannot be // one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints. +// The name a search node takes. The query is in it because the name is what the +// node is: a column of `Immich · …` rows would say nothing about which search is +// which, and the name is the store's key, so the same words are already the same +// node. Only a clash with something that is not this query's own node — a disk +// folder someone called the same — moves it on to a number. +export function immichSearchName(q: string, taken: Iterable): string { + const used = new Set(taken); + const base = `Immich · ${q}`; + let name = base; + for (let n = 2; used.has(name); n++) name = `${base} ${n}`; + return name; +} + +// Put a search in the column as a node of its own. The same words are the same +// node read again rather than one more row for every time they are typed: the +// frames are keyed by it, and a node per attempt would be a column of dead ends. +export async function searchImmichFolder( + server: ImmichServer, + query: string, + current: LibraryFolder[] +): Promise { + // Bounded the way the backend bounds it, so the name and the query are the same + // words: a row whose name is longer than the query that made it is a row nobody + // can match up again. + const q = query.trim().slice(0, 100); + const found = current.find((f) => f.kind === 'search' && f.server === server.id && f.query === q); + if (found) return found; + const folder: LibraryFolder = { + name: immichSearchName(q, current.map((f) => f.name)), + label: `Immich · ${server.name} · “${q}”`, + source: 'immich', + server: server.id, + kind: 'search', + query: q, + }; + await putFolder(folder); + return folder; +} + export async function addImmichFolder(server: ImmichServer, taken: Iterable): Promise { const folder: LibraryFolder = { name: immichFolderName(taken), diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 25be14e..b140901 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -95,8 +95,15 @@ export interface LibraryFolder { // field's empty state — every album the key can see. albums?: string[]; // 'album' for a node built from album names, 'share' for one built from a share - // link: a share link *is* one album, so it has no picker and no "Người". - kind?: 'album' | 'share'; + // link: a share link *is* one album, so it has no picker and no "Người". 'search' + // for one built from the shelf's search box: it has no album rows at all — its + // frames are the answer to one query, filed under the node itself, and `query` + // is what that query was. + kind?: 'album' | 'share' | 'search'; + // The words a 'search' node was made from. The node's name carries them too — + // the name is the store's key — but a label the reader may rename is no place to + // keep a query, and the sync asks with this. + query?: string; } export interface LibraryPhoto { @@ -568,12 +575,18 @@ export interface WalkedDirs { // Folder path in the reading's own spelling, lower-cased on the way in, to the // number of frames the walk met under it. counts: Record; + // True when the reading that wrote this met the whole of the roll it was + // pointed at. A reading that was stopped, or one kept to a branch, has no word + // on the folders it listed and never opened, and a folder it never opened is + // not a folder it found empty — so only a record that says this one may stand + // a nought beside a name. See `saveWalkedDirs`. + full?: boolean; } export async function listWalkedDirs(): Promise { try { const rows = await ask(DIRS, 'readonly', (s) => s.getAll()); - return rows.map((r) => ({ folder: r.folder, dirs: r.dirs ?? [], counts: r.counts ?? {} })); + return rows.map((r) => ({ folder: r.folder, dirs: r.dirs ?? [], counts: r.counts ?? {}, full: r.full === true })); } catch { return []; } @@ -607,8 +620,12 @@ export async function saveWalkedDirs( }; // The folders this reading was in, before another visit's are folded in. const walked = dirs.slice(); + // Read before the writing, not only when a branch is folded in: the record + // carries on saying whether a reading ever met the whole roll, and the noughts + // of the one that did are not for a branch reading to lose. See + // `WalkedDirs.full`. + const prev = replace ? null : ((await ask(DIRS, 'readonly', (s) => s.get(folder))) ?? null); if (!replace) { - const prev = (await ask(DIRS, 'readonly', (s) => s.get(folder))) ?? null; const byId = new Map((prev?.dirs ?? []).map((d) => [d.id, d])); for (const d of dirs) byId.set(d.id, d); dirs = [...byId.values()]; @@ -654,22 +671,31 @@ export async function saveWalkedDirs( // nothing under it: left out of the record, the row falls back on the // catalogue, and a frame taken off the disk keeps its number for good. const key = (rel: string) => (rel ? `${root}/${normPath(rel)}` : root).toLowerCase(); - // A folder the reading could not open is a folder it has no word on, and - // nothing — not even nothing — is what a reading with no word on a folder - // may write beside its name. Written in as a nought it takes the place of - // the number the catalogue holds, and a column drawn off a drive that - // hiccupped once reads noughts over folders full of frames until the reader - // rescans the roll. Left out, the row falls back on the catalogue, which is - // what the last reading that could open it found. - const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase())); - for (const d of walked) { - if (unsaid.has(normPath(d.rel).toLowerCase())) continue; - counts[key(d.rel)] ??= 0; + // Only a reading that met the whole of the roll gets to say nought beside a + // name. A reading kept to one folder lists the folders under it before it + // has been into them, and one cut short lists the rest of the roll and opens + // none of it: written in as noughts they take the place of the numbers the + // catalogue holds, and the record is folded back into itself, so the column + // of a roll full of frames reads empty until the reader walks it again. + // Beyond that a folder the reading could not open is a folder it has no word + // on, and nothing — not even nothing — is what it may write beside its name. + // Left out, the row falls back on the catalogue, which is what the last + // reading that could open it found. See `WalkedDirs.full`. + if (replace) { + const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase())); + for (const d of walked) { + if (unsaid.has(normPath(d.rel).toLowerCase())) continue; + counts[key(d.rel)] ??= 0; + } + counts[root] ??= 0; } - if (replace) counts[root] ??= 0; counts = fold(counts); } - await ask(DIRS, 'readwrite', (s) => s.put({ folder, dirs, counts: counts ?? {} } satisfies WalkedDirs)); + await ask( + DIRS, + 'readwrite', + (s) => s.put({ folder, dirs, counts: counts ?? {}, full: replace || prev?.full === true } satisfies WalkedDirs) + ); } catch { // A catalogue that will not keep them is a column drawn from the catalogue, as // it was before. diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 2a78f2c..8f6b412 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -293,6 +293,10 @@ export const en: Dict = { 'lib.rating': 'RATING', 'lib.ratingAll': 'Any rating', 'lib.ratingAtLeast': '{n}★ and up', + 'lib.search.label': 'Search Immich', + 'lib.search.placeholder': 'Name or tag…', + 'lib.search.hint': 'Immich: type a file name or a tag, then Enter — the answer becomes a node of its own in the tree.', + 'lib.search.noServer': 'No Immich server yet — add one first.', 'lib.starHint': 'Give this frame {n} stars', 'lib.rotateLeft': 'Turn this frame left', 'lib.rotateRight': 'Turn this frame right', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 5ca33b9..570a15e 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -305,6 +305,10 @@ export const vi = { 'lib.rating': 'XẾP HẠNG', 'lib.ratingAll': 'Mọi mức sao', 'lib.ratingAtLeast': 'Từ {n} sao', + 'lib.search.label': 'Tìm trên Immich', + 'lib.search.placeholder': 'Tên ảnh hoặc tag…', + 'lib.search.hint': 'Immich: gõ tên ảnh hoặc tên tag rồi Enter — kết quả thành một mục riêng trong cây.', + 'lib.search.noServer': 'Chưa có máy chủ Immich nào — thêm một cái trước.', 'lib.starHint': 'Cho ảnh này {n} sao', 'lib.rotateLeft': 'Xoay ảnh sang trái', 'lib.rotateRight': 'Xoay ảnh sang phải', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 13d8116..bdf7420 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1291,8 +1291,9 @@ form[data-key='immich-modal'] { /* The one line of word a reading has: what it scanned, what it repaired, what it is doing now. It sits at the foot of the column and is always there, so the frame above it does not move when a note comes and goes — hence the fixed - height, and `:empty` hiding the ink rather than the row. */ -.lib-root .lib-status { flex: 0 0 auto; min-height: 18px; margin: 6px 0 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + height, and `:empty` hiding the ink rather than the row. Kept to one short line + at 12px: it is news, not content, and the wall above it is the point. */ +.lib-root .lib-status { flex: 0 0 auto; min-height: 14px; margin: 2px 0 6px; font-size: 12px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lib-root .lib-status:empty { visibility: hidden; } /* The column: title and the folder's own chips stay put, the split takes what is left, the strip keeps its natural height on the bottom. */ @@ -1371,7 +1372,11 @@ form[data-key='immich-modal'] { } /* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on the right edge is what reaches the frames below. */ -.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; } +/* `align-content: start`, because the grid is stretched to the stage: a wall with + fewer frames than there are columns is one row, and a stretched row makes every + card's border that much taller than the picture inside it. Rows keep the height + their own frames give them however few of them there are. */ +.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; } /* What the thumbnail wall is read through: the four selects and the count of what is left. One row above the wall, and it does not scroll with it. */ .lib-root .adm-stage > .lib-filters,