diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 7dd9137..4579c87 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -6,6 +6,7 @@ import { mode, toggleMode } from 'mode-watcher'; import { toast } from 'svelte-sonner'; import { + aggregateKeywords, createFolder, createHeap, deleteFolder, @@ -16,17 +17,20 @@ heapDownloadUrl, listFolderCounts, listFolders, + listGeo, listHeaps, logout, renameFolder, renameHeap, scanCrossFolderDuplicates, triggerDownload, + type AggregatedKeyword, type CrossFolderScanResult, type PhotoMarksMap, type PpAlbum, type PpClientConfig, - type PpFolder + type PpFolder, + type PpGeoCollection } from '$lib/services/photoprism'; import { listDuplicateGroups, @@ -114,6 +118,29 @@ staleTime: 5 * 60_000 })); + // Geotagged-photo count for the Map sidebar badge. PhotoPrism's + // `count.places` is the number of distinct *locations* (cities/states), + // not the number of geotagged photos — so the sidebar would disagree + // with the "N geotagged" footer on /map. Sharing the `['geo']` cache + // keeps both numbers in lockstep and is free after /map's first visit. + const geoQuery = createQuery(() => ({ + queryKey: ['geo'], + queryFn: () => listGeo(), + enabled: isAuthenticated(), + staleTime: 5 * 60_000 + })); + + // Keywords contribution to the Tags badge. Aggregation is heavy + // (1000-photo fan-out), so the sidebar observes the cache populated + // by /tags?tab=keywords rather than triggering its own fetch — same + // lazy pattern as the cross-folder duplicates count above. + const keywordsQuery = createQuery(() => ({ + queryKey: ['photos', 'keywords'], + queryFn: aggregateKeywords, + enabled: false, + staleTime: 5 * 60_000 + })); + const ratingsCount = $derived(countRatings(marksQuery.data)); const colorsCount = $derived(countColors(marksQuery.data)); @@ -383,8 +410,9 @@ // derived value on every render — the arrays themselves are constant. // `count.all` already excludes archived/review/hidden (PhotoPrism's // "everything visible in the main timeline" tally), so it matches what - // the All photos view actually renders. `places` is the count of - // geocoded locations — semantically what the Map view groups by. + // the All photos view actually renders. Map uses the shared `['geo']` + // cache so its badge matches /map's "N geotagged" footer exactly — + // `count.places` would have shown distinct locations instead. // Review rolls in the duplicates tabs hosted under /review — stacks // always contributes; cross-folder only contributes once its tab has // been opened (the scan is lazy, not eager from the sidebar). @@ -397,10 +425,11 @@ // separate "everything regardless of folder" destination would just // duplicate it for users whose photos live under the root. const views: ViewItem[] = [ - { kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places }, + { kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }, // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors); - // the badge shows total labels + keywords + ratings + colors so the - // number reflects the combined "things you can filter by" surface. + // the badge sums each tab's badge so the sidebar number is the + // total of what the inner tabs show. Keywords is lazy — it only + // contributes after /tags?tab=keywords has been visited once. { kind: 'route', href: '/tags', @@ -408,7 +437,8 @@ getCount: () => { const labels = configQuery.data?.count?.labels; if (labels === undefined) return undefined; - return labels + ratingsCount + colorsCount; + const keywords = keywordsQuery.data?.length ?? 0; + return labels + keywords + ratingsCount + colorsCount; } } ]; diff --git a/web/src/routes/tags/+page.svelte b/web/src/routes/tags/+page.svelte index a0b17f7..687b090 100644 --- a/web/src/routes/tags/+page.svelte +++ b/web/src/routes/tags/+page.svelte @@ -78,10 +78,14 @@ } // ── Data sources ───────────────────────────────────────────────────────── + // Labels and marks are always enabled while /tags is mounted so every + // tab pill can render its count badge, not just the active tab. Both + // share queryKeys with the sidebar so the fetch is deduped. Keywords + // stays lazy (it's an O(1000-photo getPhoto fan-out) — heavy). const labelsQuery = createQuery(() => ({ queryKey: ['labels'], queryFn: listLabels, - enabled: isAuthenticated() && activeTab === 'labels' + enabled: isAuthenticated() })); const keywordsQuery = createQuery(() => ({ @@ -94,7 +98,7 @@ const marksQuery = createQuery(() => ({ queryKey: ['marks'], queryFn: getAllMarks, - enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors'), + enabled: isAuthenticated(), staleTime: 60_000 })); @@ -181,6 +185,34 @@ return out; } + // ── Per-tab badge counts ───────────────────────────────────────────────── + // Each tab pill shows what its grid covers: distinct labels/keywords for + // the bucket-style tabs, photo-count for the fixed-cardinality ones + // (ratings/colors), matching how the sidebar's Tags badge aggregates. + // `undefined` means the underlying query hasn't resolved yet — the badge + // is skipped rather than showing a misleading 0. + const labelsCount = $derived(labelsQuery.data?.length); + const keywordsCount = $derived(keywordsQuery.data?.length); + const ratedPhotosCount = $derived( + marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined + ); + const coloredPhotosCount = $derived( + marksQuery.data ? countMarked(marksQuery.data, 'color') : undefined + ); + function countMarked(marks: PhotoMarksMap, field: 'rating' | 'color'): number { + let n = 0; + for (const m of Object.values(marks)) { + if (field === 'rating' ? (m.rating ?? 0) > 0 : Boolean(m.color)) n++; + } + return n; + } + function tabCount(tab: Tab): number | undefined { + if (tab === 'labels') return labelsCount; + if (tab === 'keywords') return keywordsCount; + if (tab === 'ratings') return ratedPhotosCount; + return coloredPhotosCount; + } + // ── Sorted full lists per tab (most-common first), then page slice ─────── const labelsSorted = $derived( [...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)) @@ -285,14 +317,24 @@ reads consistently across the app. -->
{#each TABS as t (t.id)} + {@const count = tabCount(t.id)} {/each}