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;