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:
@@ -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;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user