diff --git a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte index f5240d5..f88217f 100644 --- a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte +++ b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte @@ -4,7 +4,7 @@ aggregateKeywords, getAllMarks, listLabels, - listPhotos, + listPhotosByUids, listSubjects, type AggregatedKeyword, type PhotoMarksMap, @@ -72,12 +72,17 @@ })); // Same marks-pool query the drill page uses — colors/ratings need a - // representative photo per bucket for the count rollup. Cheap once - // cached; the drill page kicks the same key. + // representative photo per bucket for the count rollup. Resolved from the + // marked UIDs (complete set, any age) so the rollup counts every marked + // photo, not just those in the newest-N timeline slice. + const markedUids = $derived(Object.keys(marksQuery.data ?? {})); const marksPoolQuery = createQuery(() => ({ - queryKey: ['photos', 'marks-pool'], - queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), - enabled: isAuthenticated() && (category === 'ratings' || category === 'colors') + queryKey: ['photos', 'marks-pool', [...markedUids].sort()], + queryFn: () => listPhotosByUids(markedUids), + enabled: + isAuthenticated() && + (category === 'ratings' || category === 'colors') && + markedUids.length > 0 })); // PhotoPrism returns labels in arbitrary order; sort by photo count diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index 664b4f4..4a21ef6 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -186,6 +186,26 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise { + if (uids.length === 0) return []; + const out: PpPhoto[] = []; + const concurrency = 8; + for (let i = 0; i < uids.length; i += concurrency) { + const slice = uids.slice(i, i + concurrency); + const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null))); + for (const p of fetched) if (p) out.push(p); + } + return out; +} + /** * Fetch a page of photos *anchored at* a specific TakenAt — `before` * older photos preceded by `after` newer ones, merged newest-first. @@ -619,17 +639,25 @@ export interface PhotoWithNote { } export async function listPhotosWithNotes(): Promise { - const list = await listPhotos({ count: 1000, order: 'newest', merged: true }); + // PhotoPrism has no "caption is not empty" search filter, so we page the + // whole library and keep the captioned rows. Paging (rather than a single + // count:1000 fetch) means notes on photos older than the newest 1000 still + // surface — the previous cap silently hid them. const out: PhotoWithNote[] = []; const seen = new Set(); - for (const p of list) { - // `merged: true` can repeat a photo across file-rows; dedupe by UID - // so the same tile doesn't render twice. - if (seen.has(p.UID)) continue; - seen.add(p.UID); - const note = p.Caption?.trim(); - if (!note) continue; - out.push({ photo: p, note }); + const PAGE = 1000; + for (let offset = 0; ; offset += PAGE) { + const list = await listPhotos({ count: PAGE, offset, order: 'newest', merged: true }); + for (const p of list) { + // `merged: true` can repeat a photo across file-rows; dedupe by UID + // so the same tile doesn't render twice. + if (seen.has(p.UID)) continue; + seen.add(p.UID); + const note = p.Caption?.trim(); + if (!note) continue; + out.push({ photo: p, note }); + } + if (list.length < PAGE) break; } return out; } diff --git a/web/src/lib/stores/bulkAction.svelte.ts b/web/src/lib/stores/bulkAction.svelte.ts index fde76a1..90e9ad1 100644 --- a/web/src/lib/stores/bulkAction.svelte.ts +++ b/web/src/lib/stores/bulkAction.svelte.ts @@ -9,7 +9,7 @@ * failBulk → tiles flash red, auto-clears after 2 s */ -import { SvelteSet } from 'svelte/reactivity'; +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; interface BulkActionState { active: boolean; @@ -18,7 +18,10 @@ interface BulkActionState { } export const bulkAction = $state({ active: false, label: '' }); -export const bulkPhotoStates = $state(new Map()); +// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile +// reliably re-runs when the entry flips — the plain-Map proxy form wasn't +// re-rendering the timeline tiles' overlay. +export const bulkPhotoStates = new SvelteMap(); /** * UIDs hidden from the timeline grid the instant a removing action (archive / diff --git a/web/src/routes/tags/[category]/[[value]]/+page.svelte b/web/src/routes/tags/[category]/[[value]]/+page.svelte index 184892b..322a22e 100644 --- a/web/src/routes/tags/[category]/[[value]]/+page.svelte +++ b/web/src/routes/tags/[category]/[[value]]/+page.svelte @@ -6,6 +6,7 @@ getPhoto, listLabels, listPhotos, + listPhotosByUids, listSubjects, type PhotoMarksMap, type PpLabel, @@ -97,10 +98,14 @@ enabled: isAuthenticated() && useLocal, staleTime: 60_000 })); + // Resolve the pool from the marked UIDs themselves (complete set, any age) + // rather than the newest-N timeline slice, so an old marked photo still + // lands in its color/rating bucket. + const markedUids = $derived(Object.keys(marksQuery.data ?? {})); const marksPoolQuery = createQuery(() => ({ - queryKey: ['photos', 'marks-pool'], - queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), - enabled: isAuthenticated() && useLocal + queryKey: ['photos', 'marks-pool', [...markedUids].sort()], + queryFn: () => listPhotosByUids(markedUids), + enabled: isAuthenticated() && useLocal && markedUids.length > 0 })); const ratingGroups = $derived(