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

@@ -24,7 +24,7 @@
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
@@ -95,10 +95,13 @@
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
// pickedPath is user-relative (listFolders strips BasePath). The
// sidecar moves files on disk so it needs a server-absolute path —
// translate before submitting.
convertMut.mutate({
uid: heap.UID,
body: {
targetFolder: pickedPath,
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap

View File

@@ -34,7 +34,7 @@
setSection,
type Section
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session } from '$lib/stores/session.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
@@ -158,15 +158,24 @@
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows the whole library — see applyFolderScope() on /
// timeline. PhotoPrism's `count.all` from /api/v1/config is the
// authoritative library total (kept in sync server-side), so use it
// directly. Earlier this subtracted Σ(folderCounts) from total, which
// worked when folderCounts were direct-child only; now that the
// sidecar fan-out recurses (see handlers_folders.go), every photo
// gets summed once per ancestor — the subtraction would double-count
// and drive rootCount to 0.
const rootCount = $derived(configQuery.data?.count?.all ?? 0);
// Root entry shows "the user's library"for admins without a
// BasePath that's still the whole library, served cheaply from
// /api/v1/config's `count.all`. For any user with a non-empty
// BasePath the precomputed total is wrong (it's library-wide), so we
// ask the sidecar for a recursive count rooted at the user's
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
// resolves to the BasePath itself, and the sidecar fan-out recurses.
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? (configQuery.data?.count?.all ?? 0)
: (scopedRootCountQuery.data?.[''] ?? 0)
);
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
@@ -565,18 +574,6 @@
counts={folderCounts}
/>
{/if}
{#if filters.folderPath && filters.folderPath !== '/'}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under

View File

@@ -23,6 +23,7 @@
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
import { userBasePath } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
@@ -71,7 +72,16 @@
}
// ── Index tab ─────────────────────────────────────────────────────────
let indexForm = $state<IndexBody>({ path: '/', rescan: false, cleanup: false });
// Default the reindex path to the user's BasePath when scoping is on,
// so non-admins (and admins-with-BasePath) only rescan their own
// subtree. PhotoPrism's /index expects originals-relative paths with
// a leading slash; `'/'` means the whole library.
const _bp = userBasePath();
let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`,
rescan: false,
cleanup: false
});
const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),