diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte
index 41b6399..a41d220 100644
--- a/web/src/lib/components/layout/LeftSidebar.svelte
+++ b/web/src/lib/components/layout/LeftSidebar.svelte
@@ -7,6 +7,7 @@
import { toast } from 'svelte-sonner';
import {
aggregateKeywords,
+ countPhotos,
createFolder,
createHeap,
deleteFolder,
@@ -95,12 +96,61 @@
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
// to any authenticated session regardless of role — the timeline
// itself IS scoped per-user, but the precomputed counters aren't.
- // Showing those numbers in a non-admin's sidebar is misleading
- // (e.g. the `test` user with role=guest saw the admin's library
- // totals next to Review / Hidden / Archive). Until PhotoPrism gains
- // per-user count scoping, just suppress the global-derived badges
- // for anyone who isn't the admin.
+ // `isAdminUser` controls the cheap path: an admin without a
+ // BasePath gets the precomputed totals from /config directly. Every
+ // other case (non-admin, or admin scoped to a subfolder) goes
+ // through `countPhotos()` which appends a `path:*` filter so
+ // the badge matches what the user can actually see.
const isAdminUser = $derived(session.user?.Role === 'admin');
+ const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
+
+ // Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
+ // admin with `BasePath === ""` gets a no-op clause and the global
+ // query; everyone else gets a `path:` clause anchored to their
+ // BasePath so unrelated folders never contribute to the badge.
+ // Non-admins with no BasePath have nothing they can see, so we
+ // short-circuit to a query that returns zero (`uid:none`).
+ function scoped(filter: string): string {
+ const bp = userBasePath();
+ if (isAdminUser && bp === '') return filter;
+ if (!isAdminUser && bp === '') return 'uid:none';
+ return `${filter} path:"${bp}*"`.trim();
+ }
+
+ function scopedCountQuery(key: string, filter: string) {
+ return createQuery(() => ({
+ queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
+ queryFn: () => countPhotos(scoped(filter)),
+ enabled: isAuthenticated() && wantScoped,
+ staleTime: 60_000
+ }));
+ }
+
+ // One query per badge. Admins with no BasePath skip all of these
+ // (enabled:false via `wantScoped`) and the configQuery numbers are
+ // used directly — same chrome as before that fix, no extra
+ // round-trips.
+ const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
+ const reviewCountQuery = scopedCountQuery('review', 'review:true');
+ const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
+ const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
+ const labelsCountQuery = scopedCountQuery('labels', 'all:true label:*');
+
+ function bucketCount(
+ key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels',
+ query: { data: number | undefined; isPending: boolean }
+ ): number | undefined {
+ if (wantScoped) {
+ if (query.isPending) return undefined;
+ return query.data;
+ }
+ // Admin + no BasePath: use the precomputed PhotoPrism counters
+ // (no extra round-trip).
+ const c = configQuery.data?.count;
+ if (!c) return undefined;
+ if (key === 'labels') return c.labels;
+ return c[key];
+ }
const marksQuery = createQuery(() => ({
queryKey: ['marks'],
@@ -229,6 +279,15 @@
: (scopedRootCountQuery.data?.[''] ?? 0)
);
+ // Favorites / Review / Hidden / Archive nav entries use these
+ // derived values rather than peeking at configQuery directly so the
+ // scoped path is invisible to the views[]/manageViews[] declarations.
+ const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
+ const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
+ const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
+ const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
+ const labelsBadge = $derived(bucketCount('labels', labelsCountQuery));
+
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
onSuccess: (h) => {
@@ -437,23 +496,23 @@
// 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: () => (isAdminUser ? geoQuery.data?.features?.length : undefined) },
- // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
- // 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.
- // Suppressed for non-admins because every contributing query is
- // library-wide rather than per-user.
+ // Map's `geoQuery` already returns the GeoJSON the user is
+ // 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 rolls up labels + keywords + ratings + colors. Labels
+ // flows through countPhotos (scoped); keywords/ratings/colors are
+ // from library-wide marks tables and only contribute when we're
+ // in admin-without-BasePath mode (their sources don't scope).
{
kind: 'route',
href: '/tags',
label: 'Tags',
getCount: () => {
- if (!isAdminUser) return undefined;
- const labels = configQuery.data?.count?.labels;
- if (labels === undefined) return undefined;
+ if (labelsBadge === undefined) return undefined;
+ if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0;
- return labels + keywords + ratingsCount + colorsCount;
+ return labelsBadge + keywords + ratingsCount + colorsCount;
}
}
];
@@ -464,14 +523,15 @@
href: '/review',
label: 'Review',
getCount: () => {
- if (!isAdminUser) return undefined;
- const review = configQuery.data?.count?.review;
- if (review === undefined) return undefined;
- return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
+ if (reviewBadge === undefined) return undefined;
+ // The two duplicates queries are library-wide; only admins
+ // without a BasePath roll them into the Review badge.
+ if (wantScoped) return reviewBadge;
+ return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
}
},
- { kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => (isAdminUser ? configQuery.data?.count?.hidden : undefined) },
- { kind: 'section', id: 'archive', label: 'Archive', getCount: () => (isAdminUser ? configQuery.data?.count?.archived : undefined) }
+ { kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
+ { kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
];
function isRouteActive(href: string): boolean {
diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts
index f82e8c4..dd3c5b8 100644
--- a/web/src/lib/services/photoprism.ts
+++ b/web/src/lib/services/photoprism.ts
@@ -155,6 +155,28 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise {
+ const resp = await http.get('/photos', {
+ params: { count: 10000, offset: 0, merged: false, q }
+ });
+ const header = resp.headers['x-count'];
+ const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
+ return Number.isFinite(n) ? n : 0;
+}
+
export async function getPhoto(uid: string): Promise {
const { data } = await http.get(`/photos/${uid}`);
return data;
diff --git a/web/src/lib/types/photoprism.ts b/web/src/lib/types/photoprism.ts
index 014190d..7dc1fd7 100644
--- a/web/src/lib/types/photoprism.ts
+++ b/web/src/lib/types/photoprism.ts
@@ -56,6 +56,7 @@ export interface PpClientConfig {
archived?: number;
hidden?: number;
review?: number;
+ favorites?: number;
albums?: number;
moments?: number;
months?: number;