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 { mode, toggleMode } from 'mode-watcher';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
aggregateKeywords,
createFolder, createFolder,
createHeap, createHeap,
deleteFolder, deleteFolder,
@@ -16,17 +17,20 @@
heapDownloadUrl, heapDownloadUrl,
listFolderCounts, listFolderCounts,
listFolders, listFolders,
listGeo,
listHeaps, listHeaps,
logout, logout,
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
triggerDownload, triggerDownload,
type AggregatedKeyword,
type CrossFolderScanResult, type CrossFolderScanResult,
type PhotoMarksMap, type PhotoMarksMap,
type PpAlbum, type PpAlbum,
type PpClientConfig, type PpClientConfig,
type PpFolder type PpFolder,
type PpGeoCollection
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
listDuplicateGroups, listDuplicateGroups,
@@ -114,6 +118,29 @@
staleTime: 5 * 60_000 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 ratingsCount = $derived(countRatings(marksQuery.data));
const colorsCount = $derived(countColors(marksQuery.data)); const colorsCount = $derived(countColors(marksQuery.data));
@@ -383,8 +410,9 @@
// derived value on every render — the arrays themselves are constant. // derived value on every render — the arrays themselves are constant.
// `count.all` already excludes archived/review/hidden (PhotoPrism's // `count.all` already excludes archived/review/hidden (PhotoPrism's
// "everything visible in the main timeline" tally), so it matches what // "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of // the All photos view actually renders. Map uses the shared `['geo']`
// geocoded locations — semantically what the Map view groups by. // 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 // Review rolls in the duplicates tabs hosted under /review — stacks
// always contributes; cross-folder only contributes once its tab has // always contributes; cross-folder only contributes once its tab has
// been opened (the scan is lazy, not eager from the sidebar). // been opened (the scan is lazy, not eager from the sidebar).
@@ -397,10 +425,11 @@
// separate "everything regardless of folder" destination would just // separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root. // duplicate it for users whose photos live under the root.
const views: ViewItem[] = [ 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); // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
// the badge shows total labels + keywords + ratings + colors so the // the badge sums each tab's badge so the sidebar number is the
// number reflects the combined "things you can filter by" surface. // total of what the inner tabs show. Keywords is lazy — it only
// contributes after /tags?tab=keywords has been visited once.
{ {
kind: 'route', kind: 'route',
href: '/tags', href: '/tags',
@@ -408,7 +437,8 @@
getCount: () => { getCount: () => {
const labels = configQuery.data?.count?.labels; const labels = configQuery.data?.count?.labels;
if (labels === undefined) return undefined; 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 ───────────────────────────────────────────────────────── // ── 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[]>(() => ({ const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'], queryKey: ['labels'],
queryFn: listLabels, queryFn: listLabels,
enabled: isAuthenticated() && activeTab === 'labels' enabled: isAuthenticated()
})); }));
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({ const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
@@ -94,7 +98,7 @@
const marksQuery = createQuery<PhotoMarksMap>(() => ({ const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'], queryKey: ['marks'],
queryFn: getAllMarks, queryFn: getAllMarks,
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors'), enabled: isAuthenticated(),
staleTime: 60_000 staleTime: 60_000
})); }));
@@ -181,6 +185,34 @@
return out; 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 ─────── // ── Sorted full lists per tab (most-common first), then page slice ───────
const labelsSorted = $derived( const labelsSorted = $derived(
[...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)) [...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0))
@@ -285,14 +317,24 @@
reads consistently across the app. --> reads consistently across the app. -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#each TABS as t (t.id)} {#each TABS as t (t.id)}
{@const count = tabCount(t.id)}
<button <button
type="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-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}" : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)} onclick={() => setTab(t.id)}
> >
{t.label} {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> </button>
{/each} {/each}
</div> </div>