Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
3 changed files with 105 additions and 22 deletions
Showing only changes of commit 85847848c4 - Show all commits

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ export interface PpClientConfig {
archived?: number;
hidden?: number;
review?: number;
favorites?: number;
albums?: number;
moments?: number;
months?: number;