fix(library): a reading that never finished no longer says nought

`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.
This commit is contained in:
2026-10-10 18:27:49 +07:00
parent ed179fc200
commit 3befdf50eb
8 changed files with 267 additions and 34 deletions
+64 -2
View File
@@ -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<string, number> = {};
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 }) {
))}
</select>
{/* 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. */}
<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>