feat(web): scope library views to per-user BasePath

PhotoPrism's user entity carries a per-user BasePath; the web app now
mirrors that scope client-side so each user sees only their own subtree
in the sidebar, timeline, folder counts, and heap-convert target picker.
Admin without a BasePath is unchanged. Also removes the redundant
"✕ <folder>" pill below the folder tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:18:13 +02:00
parent bd39d310ab
commit 79a9ef49d4
7 changed files with 151 additions and 46 deletions

View File

@@ -1,7 +1,14 @@
import axios, { AxiosError, type AxiosInstance } from 'axios';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
import {
adoptSession,
clearSession,
session,
toOriginalsPath,
toUserPath,
userBasePath
} from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism';
import type {
PpClientConfig,
@@ -325,13 +332,26 @@ export interface PpFolder {
* Recursive list of subfolders under originals/. `uncached=true` because
* PhotoPrism's folder cache lags new folders by a noticeable interval and
* mule-image's folder tree expects to surface mutations immediately.
*
* Scoped to the signed-in user's `BasePath` on the way out: server-absolute
* `Path` values get rewritten to user-relative (e.g. `users/alice/2024/01`
* → `2024/01`) so every downstream consumer (FolderTree, sidebar, heap
* convert picker) sees folders relative to the user's root. The BasePath
* row itself is dropped — the sidebar synthesises the root entry. When
* BasePath is empty (today's admin default) this is a no-op.
*/
export async function listFolders(): Promise<PpFolder[]> {
const { data } = await http.get<{ folders?: PpFolder[] }>(
'/folders/originals',
{ params: { recursive: true, uncached: true, files: false } }
);
return data.folders ?? [];
const bp = userBasePath();
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
.filter((f) => f.Path !== '');
}
/**
@@ -340,6 +360,11 @@ export async function listFolders(): Promise<PpFolder[]> {
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
* `/folders/originals`, but the photo counts come from `X-Files` and
* `X-Folders` response headers since the body only lists subfolders.
*
* BasePath does NOT apply: `/folders/import` is a separate root from
* originals (PhotoPrism's `import.path`, not under originals/), so we
* don't filter the result by the signed-in user's BasePath. If/when
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
*/
export interface ImportInfo {
files: number;
@@ -385,11 +410,21 @@ export async function getImportInfo(): Promise<ImportInfo> {
*/
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
if (paths.length === 0) return {};
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
// The sidecar walks the real filesystem and queries PhotoPrism with
// server-absolute paths, but callers hand us user-relative paths
// (because that's what `listFolders` returns post-scoping). Translate
// on the way out, then re-key the response back to user-relative on
// the way in so callers' map keys line up with their input array.
const serverPaths = paths.map((p) => toOriginalsPath(p));
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
string,
number
>;
return data;
const out: Record<string, number> = {};
for (let i = 0; i < paths.length; i += 1) {
out[paths[i]] = data[serverPaths[i]] ?? 0;
}
return out;
}
// ── Geo ──────────────────────────────────────────────────────────────────────