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:
@@ -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<PpPhoto[]>(() => ({
|
||||
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
|
||||
|
||||
@@ -186,6 +186,26 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
||||
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`
|
||||
* older photos preceded by `after` newer ones, merged newest-first.
|
||||
@@ -619,17 +639,25 @@ export interface 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 seen = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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 /
|
||||
|
||||
@@ -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<PpPhoto[]>(() => ({
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user