web(sidebar): drop count badges from Map and Tags rows
Per-tag totals are already surfaced by the TagsBrowserSidebar, so the main sidebar's Map/Tags rows stay as pure navigators. Also removes the now-orphaned geo, marks, and keywords cache observers that only fed those badges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,25 +13,20 @@
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
deleteHeap,
|
deleteHeap,
|
||||||
duplicateHeap,
|
duplicateHeap,
|
||||||
getAllMarks,
|
|
||||||
getConfig,
|
getConfig,
|
||||||
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 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,
|
||||||
@@ -137,19 +132,12 @@
|
|||||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||||
// used directly — same chrome as before that fix, no extra
|
// used directly — same chrome as before that fix, no extra
|
||||||
// round-trips.
|
// round-trips.
|
||||||
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
|
|
||||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
||||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||||
// Labels: PhotoPrism's q-DSL has no "has any label" predicate
|
|
||||||
// (`label:*` matches every photo, `label:<slug>` only matches the
|
|
||||||
// named label), so the sidebar uses `configQuery.count.labels` — the
|
|
||||||
// precomputed count of distinct label slugs. Same source the
|
|
||||||
// `tagCategoryCount('labels')` sub-row already uses, so the parent
|
|
||||||
// Tags badge sums the same numbers the sub-rows display.
|
|
||||||
|
|
||||||
function bucketCount(
|
function bucketCount(
|
||||||
key: 'favorites' | 'review' | 'hidden' | 'archived',
|
key: 'review' | 'hidden' | 'archived',
|
||||||
query: { data: number | undefined; isPending: boolean }
|
query: { data: number | undefined; isPending: boolean }
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (wantScoped) {
|
if (wantScoped) {
|
||||||
@@ -163,13 +151,6 @@
|
|||||||
return c[key];
|
return c[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
|
||||||
queryKey: ['marks'],
|
|
||||||
queryFn: getAllMarks,
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
staleTime: 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
||||||
// PhotoPrism query so we always fetch it; cross-folder is an
|
// PhotoPrism query so we always fetch it; cross-folder is an
|
||||||
// O(disk) scan, so the sidebar only *observes* its cache
|
// O(disk) scan, so the sidebar only *observes* its cache
|
||||||
@@ -189,50 +170,6 @@
|
|||||||
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 colorsCount = $derived(countColors(marksQuery.data));
|
|
||||||
|
|
||||||
function countRatings(marks: PhotoMarksMap | undefined): number {
|
|
||||||
if (!marks) return 0;
|
|
||||||
let n = 0;
|
|
||||||
for (const m of Object.values(marks)) {
|
|
||||||
if ((m.rating ?? 0) > 0) n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countColors(marks: PhotoMarksMap | undefined): number {
|
|
||||||
if (!marks) return 0;
|
|
||||||
let n = 0;
|
|
||||||
for (const m of Object.values(marks)) {
|
|
||||||
if (m.color) n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderTree = $derived(
|
const folderTree = $derived(
|
||||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||||
);
|
);
|
||||||
@@ -290,16 +227,12 @@
|
|||||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Favorites / Review / Hidden / Archive nav entries use these
|
// Review / Hidden / Archive nav entries use these derived values
|
||||||
// derived values rather than peeking at configQuery directly so the
|
// rather than peeking at configQuery directly so the scoped path
|
||||||
// scoped path is invisible to the views[]/manageViews[] declarations.
|
// is invisible to the manageViews[] declarations.
|
||||||
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
|
|
||||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
||||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||||
const labelsBadge = $derived<number | undefined>(
|
|
||||||
configQuery.data?.count?.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMut = createMutation(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
@@ -391,24 +324,6 @@
|
|||||||
ratings: 'Ratings'
|
ratings: 'Ratings'
|
||||||
};
|
};
|
||||||
|
|
||||||
function tagCategoryCount(cat: TagCategory): number | undefined {
|
|
||||||
// Labels reads PhotoPrism's pre-computed distinct-label counter
|
|
||||||
// (`/api/v1/config` → count.labels), not the photo-count from
|
|
||||||
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
|
|
||||||
// whose indexer hadn't surfaced labelled photos yet, leaving the
|
|
||||||
// badge silently empty; the precomputed counter is always present
|
|
||||||
// and reads as "how many labels you can pick from", matching the
|
|
||||||
// Keywords sub-row's distinct-count semantics.
|
|
||||||
if (cat === 'labels') return configQuery.data?.count?.labels;
|
|
||||||
if (cat === 'keywords') return keywordsQuery.data?.length;
|
|
||||||
// People follows the same distinct-count semantics as Labels —
|
|
||||||
// `/api/v1/config.count.people` is the number of named subjects PP
|
|
||||||
// has clustered, surfaced eagerly without a separate /subjects fetch.
|
|
||||||
if (cat === 'people') return configQuery.data?.count?.people;
|
|
||||||
if (cat === 'ratings') return ratingsCount;
|
|
||||||
return colorsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||||
return page.url.pathname.startsWith(`/tags/${cat}`);
|
return page.url.pathname.startsWith(`/tags/${cat}`);
|
||||||
}
|
}
|
||||||
@@ -554,14 +469,12 @@
|
|||||||
//
|
//
|
||||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||||
// 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
|
// Map and Tags intentionally render without a count badge; the count
|
||||||
// "everything visible in the main timeline" tally), so it matches what
|
// columns inside the TagsBrowserSidebar are the canonical surface for
|
||||||
// the All photos view actually renders. Map uses the shared `['geo']`
|
// per-tag totals. Review rolls in the duplicates tabs hosted under
|
||||||
// cache so its badge matches /map's "N geotagged" footer exactly —
|
// /review — stacks always contributes; cross-folder only contributes
|
||||||
// `count.places` would have shown distinct locations instead.
|
// once its tab has been opened (the scan is lazy, not eager from the
|
||||||
// Review rolls in the duplicates tabs hosted under /review — stacks
|
// sidebar).
|
||||||
// always contributes; cross-folder only contributes once its tab has
|
|
||||||
// been opened (the scan is lazy, not eager from the sidebar).
|
|
||||||
type ViewItem =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||||
@@ -571,28 +484,13 @@
|
|||||||
// 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[] = [
|
||||||
// Map's `geoQuery` already returns the GeoJSON the user is
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
|
||||||
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
|
||||||
// so the badge is per-user-correct without extra scoping.
|
|
||||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
|
|
||||||
// Tags is rendered as a bespoke expandable block below the
|
// Tags is rendered as a bespoke expandable block below the
|
||||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||||
// Ratings) and a chevron, neither of which fits the flat
|
// Ratings) and a chevron, neither of which fits the flat
|
||||||
// section/route ViewItem shape.
|
// section/route ViewItem shape.
|
||||||
];
|
];
|
||||||
|
|
||||||
// Total badge for the "Tags" header row. Sum of the same per-category
|
|
||||||
// counts the sub-rows render (labels/people: distinct slugs/subjects,
|
|
||||||
// keywords: distinct keywords, ratings/colors: photos carrying each
|
|
||||||
// mark). Library-wide numbers — the badge is a quick "how many tags
|
|
||||||
// exist?" rollup, not a per-user-visible count.
|
|
||||||
const tagsTotal = $derived.by<number | undefined>(() => {
|
|
||||||
if (labelsBadge === undefined) return undefined;
|
|
||||||
const keywords = keywordsQuery.data?.length ?? 0;
|
|
||||||
const people = configQuery.data?.count?.people ?? 0;
|
|
||||||
return labelsBadge + keywords + people + ratingsCount + colorsCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
const manageViews: ViewItem[] = [
|
const manageViews: ViewItem[] = [
|
||||||
{
|
{
|
||||||
kind: 'route',
|
kind: 'route',
|
||||||
@@ -900,9 +798,10 @@
|
|||||||
{@render viewRow(v)}
|
{@render viewRow(v)}
|
||||||
{/each}
|
{/each}
|
||||||
<!--
|
<!--
|
||||||
Tags expandable. Whole row is a toggle (chevron + label + badge);
|
Tags expandable. Whole row is a toggle (chevron + label); there is
|
||||||
there is no landing page at /tags — selecting a sub-category is the
|
no landing page at /tags — selecting a sub-category is the only way
|
||||||
only way into a real view.
|
into a real view. Counts intentionally live in the TagsBrowserSidebar
|
||||||
|
(secondary sidebar) so this row stays a pure navigator.
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -919,19 +818,11 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
<span class="truncate">Tags</span>
|
<span class="truncate">Tags</span>
|
||||||
{#if tagsTotal !== undefined}
|
|
||||||
<span
|
|
||||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
|
|
||||||
>
|
|
||||||
{tagsTotal}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{#if tagsExpanded}
|
{#if tagsExpanded}
|
||||||
{#each TAG_CATEGORIES as cat (cat)}
|
{#each TAG_CATEGORIES as cat (cat)}
|
||||||
{@const active = isTagCategoryActive(cat)}
|
{@const active = isTagCategoryActive(cat)}
|
||||||
{@const count = tagCategoryCount(cat)}
|
|
||||||
<a
|
<a
|
||||||
href={`/tags/${cat}`}
|
href={`/tags/${cat}`}
|
||||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
@@ -943,15 +834,6 @@
|
|||||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||||
>
|
>
|
||||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||||
{#if count !== undefined}
|
|
||||||
<span
|
|
||||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
|
||||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
|
||||||
: 'bg-secondary text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user