|
|
|
|
@@ -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 {
|
|
|
|
|
|