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