diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 3f007ad..53f15c9 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -110,6 +110,7 @@ const OPEN_KEY = 'recipescam.library.open'; // 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'; @@ -460,6 +461,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { 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 @@ -959,6 +967,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { useEffect(() => { keep(OPEN_KEY, [...expanded].join(',')); }, [expanded]); + useEffect(() => { + keep(DEEP_KEY, String(deep)); + }, [deep]); useEffect(() => { keep(VIEW_KEY, view); }, [view]); @@ -1533,7 +1544,10 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // folder — that one is not a folder of this roll. const relOf = useCallback( (key: string): string | null => { - if (!rootName) return 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 ''; @@ -1602,9 +1616,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // 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: true, rows }); + void makeThumbsFor(rootFolder, rel, { below: deep, rows }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]); + }, [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 @@ -1805,28 +1819,36 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { 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 those of every folder under it, - // which is the whole of the branch the row above it counted. + // 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 (!rootName) return photos; + // The empty root is a root: `D:\` is what the picker calls a drive, and `''` + // is what it folds to. Read as "no folder picked" it hands back the whole + // catalogue for every row of that roll — the root's frames under a year, and + // a server's frames under both. + if (rootName === null) return photos; const normRoot = normPath(rootName); const normNode = normPath(node ?? ''); const dir = normNode === normRoot ? '' : normNode.slice(normRoot.length + 1); const below = dir === '' ? (p: LibraryPhoto) => normPath(p.dir ?? '') !== '' : (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`); + const own: LibraryPhoto[] = []; const both: LibraryPhoto[] = []; for (const p of photos) { if (normPath(p.folder ?? '') !== normRoot) continue; const pDir = normPath(p.dir ?? ''); - if (pDir === dir || below(p)) both.push(p); + const isOwn = pDir === dir; + if (isOwn) own.push(p); + if (isOwn || below(p)) both.push(p); } - // A folder is its branch: the row counts what sits at or under the folder, so - // the wall under it holds the same frames, and a frame two levels down is not - // a frame the reader has to go and find. A sibling's frames are the one thing - // left out. - return both; - }, [node, photos, rootName]); + // A row counts what sits at or under its folder, so a folder with nothing of + // its own to draw is drawn as its branch whatever the toggle says: a folder + // whose frames all lie one level down would otherwise open on an empty screen + // under a number that says otherwise. + return deep || !own.length ? both : own; + }, [deep, node, photos, rootName]); // What the thumbnail view draws, out of what the open node holds. A frame's // hour is the hour the shutter fired in, on this machine's clock — the same @@ -1919,7 +1941,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // 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, rootName, years]); + }, [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( @@ -2908,6 +2930,19 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { + {/* 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. */} + ) : null} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 50daa1e..3176faa 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -282,6 +282,9 @@ export const en: Dict = { 'lib.rail': 'Timeline', 'lib.prev': 'Previous frame', 'lib.next': 'Next frame', + 'lib.subsShow': 'SHOW SUBFOLDERS', + 'lib.subsHide': 'THIS FOLDER ONLY', + 'lib.subsHint': 'Frames in subfolders: the whole branch under the open folder, or only what that folder holds itself.', 'lib.open': 'Open in the studio', 'lib.openExplorer': 'Open in window explorer', 'lib.openLocation': 'Open at location', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 052946c..ae9117c 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -293,6 +293,10 @@ export const vi = { 'lib.rail': 'Dòng thời gian', 'lib.prev': 'Ảnh trước', 'lib.next': 'Ảnh sau', + 'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON', + 'lib.subsHide': 'CHỈ THƯ MỤC ĐANG CHỌN', + 'lib.subsHint': + 'Ảnh trong thư mục con: hiện cả nhánh dưới thư mục đang chọn, hoặc chỉ ảnh của chính thư mục đó.', 'lib.open': 'Mở trong studio', 'lib.openExplorer': 'Open in window explorer', 'lib.openLocation': 'Open at location', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 1041599..945caad 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1555,15 +1555,15 @@ form[data-key='immich-modal'] { read by the year first and the month under it after. */ .lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; } .lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); } -/* A year is the ruler's own rule: a hairline across the column with the year in - brackets at the end of it — `-----[2024]`, which is what a ruler reads like. - The line is the months' own grey, since a year is a tick like any other and not - a band; the brackets are drawn here so the tick still reads as its bare year. */ +/* A year is the ruler's own rule: `-----2024`, a run of rule across the column + with the bare year at the end of it, which is what a ruler's own edge reads + like. The run is the page's ruler ink at its heavy weight — the ink the ticks + are drawn in, one weight up, so a year separates where a month only marks — and + not the solid `--text-dim` it first was, which stood out of the column like a + border. */ .lib-ruler-tick.lib-ruler-year { left: 0; right: 0; width: auto; height: 1px; background: none; display: flex; align-items: center; gap: 4px; } -.lib-ruler-tick.lib-ruler-year::before { content: ''; flex: 1 1 auto; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); } +.lib-ruler-tick.lib-ruler-year::before { content: ''; flex: 1 1 auto; height: 1px; background: var(--tick-strong); } .lib-ruler-tick.lib-ruler-year i { position: static; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.02em; color: var(--text-dim); } -.lib-ruler-tick.lib-ruler-year i::before { content: '['; } -.lib-ruler-tick.lib-ruler-year i::after { content: ']'; } .lib-ruler-tick.lib-ruler-year.on { width: auto; background: none; } .lib-ruler-tick.lib-ruler-year.on::before { background: var(--accent); } .lib-ruler-tick.lib-ruler-year.on i { color: var(--accent); }