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;
}
}
];

View File

@@ -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<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated() && activeTab === 'labels'
enabled: isAuthenticated()
}));
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
@@ -94,7 +98,7 @@
const marksQuery = createQuery<PhotoMarksMap>(() => ({
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<number | undefined>(labelsQuery.data?.length);
const keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
const ratedPhotosCount = $derived<number | undefined>(
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
);
const coloredPhotosCount = $derived<number | undefined>(
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. -->
<div class="flex items-center gap-1">
{#each TABS as t (t.id)}
{@const count = tabCount(t.id)}
<button
type="button"
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)}
>
{t.label}
{#if count !== undefined}
<span
class="tabular-nums text-[10px] {activeTab === t.id
? 'text-primary/70'
: 'text-muted-foreground/70'}"
>
{count}
</span>
{/if}
</button>
{/each}
</div>