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,
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 /
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user