Sidebar: scope count badges to current user's library
PhotoPrism's /api/v1/config.count is library-wide and the same value for every authenticated session. That made non-admins (and admins with a non-empty BasePath) see badges that didn't match what the timeline actually showed them. Replaces the direct `configQuery.data?.count?.<bucket>` reads in LeftSidebar with per-bucket queries against PhotoPrism's /photos endpoint. The new `countPhotos(q)` helper sets `count=10000` and reads the X-Count response header to get the true total in one round -trip (PhotoPrism's ACL filter is what scopes the result, so the header reflects "what this session can see"). Each bucket query appends `path:"<BasePath>*"` so admins-with-a- BasePath stay scoped too; non-admins without a BasePath short-circuit to `uid:none` (their effective visibility is zero, no point querying). Admins without a BasePath skip the scoped queries entirely and keep using the precomputed /config totals — same network footprint as before for the common case. Affected badges: Favorites, Hidden, Archive, Review, Tags (labels component). Map already used `geoQuery` whose result is ACL-filtered server-side, so its badge is per-user-correct without changes. The `favorites` field was missing from PpClientConfig.count's TypeScript type; added it. Resolves the `test`-user complaint: sidebar showed the admin library's totals next to Review / Hidden / Archive / Favorites because those numbers came from /config, not from a user-scoped query.
This commit is contained in:
@@ -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:<base>*` 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<number>(() => ({
|
||||
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<PhotoMarksMap>(() => ({
|
||||
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 {
|
||||
|
||||
@@ -155,6 +155,28 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count photos matching a DSL query, scoped to whatever the caller's
|
||||
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
||||
* only" endpoint, but the `X-Count` header on `/photos` returns the
|
||||
* page size — which equals the total when `count` is set above the
|
||||
* library size. 10000 is generously above the realistic per-user
|
||||
* library and well under PhotoPrism's server-side ceiling, so a single
|
||||
* round-trip yields the true total without paginating.
|
||||
*
|
||||
* Used by the LeftSidebar to render bucket badges that reflect what
|
||||
* the signed-in user actually sees, not the global library aggregate
|
||||
* exposed by `/config.count`.
|
||||
*/
|
||||
export async function countPhotos(q: string): Promise<number> {
|
||||
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<PpPhoto> {
|
||||
const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
|
||||
return data;
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface PpClientConfig {
|
||||
archived?: number;
|
||||
hidden?: number;
|
||||
review?: number;
|
||||
favorites?: number;
|
||||
albums?: number;
|
||||
moments?: number;
|
||||
months?: number;
|
||||
|
||||
Reference in New Issue
Block a user