fix: tile overlay reactivity + facets showing all marked/noted photos

Problem 1 — per-photo progress overlay never rendered on the grid:
- bulkPhotoStates was `$state(new Map())`; a `.get(uid)` read in PhotoTile
  didn't reliably re-run when the entry flipped, so the spinner/check/X
  overlay never appeared. Switch to SvelteMap (svelte/reactivity).

Problem 2 — Notes / Colors / Ratings only showed the newest ~1000 photos:
- All three derived from `listPhotos({ count: 1000 })`, silently hiding
  older marked/noted photos.
- listPhotosWithNotes now pages the whole library.
- Add listPhotosByUids() and resolve the Colors/Ratings marks-pool from the
  complete marked-UID set (from getAllMarks) instead of the newest slice;
  wire it into the TagsBrowserSidebar panel and the tag drill page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:37:30 +02:00
parent ccf2c6b7c7
commit 259adb6a41
4 changed files with 61 additions and 20 deletions

View File

@@ -4,7 +4,7 @@
aggregateKeywords, aggregateKeywords,
getAllMarks, getAllMarks,
listLabels, listLabels,
listPhotos, listPhotosByUids,
listSubjects, listSubjects,
type AggregatedKeyword, type AggregatedKeyword,
type PhotoMarksMap, type PhotoMarksMap,
@@ -72,12 +72,17 @@
})); }));
// Same marks-pool query the drill page uses — colors/ratings need a // Same marks-pool query the drill page uses — colors/ratings need a
// representative photo per bucket for the count rollup. Cheap once // representative photo per bucket for the count rollup. Resolved from the
// cached; the drill page kicks the same key. // 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<PpPhoto[]>(() => ({ const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'], queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), queryFn: () => listPhotosByUids(markedUids),
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors') enabled:
isAuthenticated() &&
(category === 'ratings' || category === 'colors') &&
markedUids.length > 0
})); }));
// PhotoPrism returns labels in arbitrary order; sort by photo count // PhotoPrism returns labels in arbitrary order; sort by photo count

View File

@@ -186,6 +186,26 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
return data; return data;
} }
/**
* Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings
* facets, whose member set comes from the mule-sidecar marks store and is NOT
* bounded to the newest N photos — a marked photo anywhere in the library must
* resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid`
* endpoint the metadata panel uses, so it can't drift from PhotoPrism's search
* DSL. Missing UIDs (deleted since marked) are skipped.
*/
export async function listPhotosByUids(uids: string[]): Promise<PpPhoto[]> {
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` * Fetch a page of photos *anchored at* a specific TakenAt — `before`
* older photos preceded by `after` newer ones, merged newest-first. * older photos preceded by `after` newer ones, merged newest-first.
@@ -619,17 +639,25 @@ export interface PhotoWithNote {
} }
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> { export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
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 out: PhotoWithNote[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const p of list) { const PAGE = 1000;
// `merged: true` can repeat a photo across file-rows; dedupe by UID for (let offset = 0; ; offset += PAGE) {
// so the same tile doesn't render twice. const list = await listPhotos({ count: PAGE, offset, order: 'newest', merged: true });
if (seen.has(p.UID)) continue; for (const p of list) {
seen.add(p.UID); // `merged: true` can repeat a photo across file-rows; dedupe by UID
const note = p.Caption?.trim(); // so the same tile doesn't render twice.
if (!note) continue; if (seen.has(p.UID)) continue;
out.push({ photo: p, note }); seen.add(p.UID);
const note = p.Caption?.trim();
if (!note) continue;
out.push({ photo: p, note });
}
if (list.length < PAGE) break;
} }
return out; return out;
} }

View File

@@ -9,7 +9,7 @@
* failBulk → tiles flash red, auto-clears after 2 s * failBulk → tiles flash red, auto-clears after 2 s
*/ */
import { SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface BulkActionState { interface BulkActionState {
active: boolean; active: boolean;
@@ -18,7 +18,10 @@ interface BulkActionState {
} }
export const bulkAction = $state<BulkActionState>({ active: false, label: '' }); export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
export const bulkPhotoStates = $state(new Map<string, 'pending' | 'done' | 'error'>()); // 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<string, 'pending' | 'done' | 'error'>();
/** /**
* UIDs hidden from the timeline grid the instant a removing action (archive / * UIDs hidden from the timeline grid the instant a removing action (archive /

View File

@@ -6,6 +6,7 @@
getPhoto, getPhoto,
listLabels, listLabels,
listPhotos, listPhotos,
listPhotosByUids,
listSubjects, listSubjects,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel, type PpLabel,
@@ -97,10 +98,14 @@
enabled: isAuthenticated() && useLocal, enabled: isAuthenticated() && useLocal,
staleTime: 60_000 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<PpPhoto[]>(() => ({ const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'], queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), queryFn: () => listPhotosByUids(markedUids),
enabled: isAuthenticated() && useLocal enabled: isAuthenticated() && useLocal && markedUids.length > 0
})); }));
const ratingGroups = $derived( const ratingGroups = $derived(