feat(sidebar,tags): per-tab counts and aggregated Tags badge

- /tags: each tab pill shows its own count (labels/keywords =
  distinct tags, ratings/colors = photos covered). Labels and marks
  queries become always-enabled on the route so every pill resolves
  immediately; keywords stays lazy.
- LeftSidebar: Map badge now reads from the shared `['geo']` cache so
  it matches /map's "N geotagged" footer instead of count.places
  (distinct locations). Tags badge sums the four inner counts;
  keywords contributes lazily once /tags?tab=keywords is visited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:59:47 +02:00
parent d70244f17e
commit 829d7bed83
2 changed files with 82 additions and 10 deletions

View File

@@ -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<PpGeoCollection>(() => ({
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<AggregatedKeyword[]>(() => ({
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;
}
}
];