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

@@ -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>