feat(settings,index): per-user index sub-path, strip dead PP settings, scope duplicates

Lets a user pick a sub-folder under their library as a working index root,
stored server-side (new sidecar user_prefs table). The Library tree, reindex,
and both duplicate views (stacks + cross-folder scan) now re-root to it via a
single userLibraryBase() helper. Also fixes the cross-folder scan/archive
endpoints, which previously walked/touched the whole originals root instead
of being scoped per-user (archive now rejects out-of-scope paths, 403).

Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only
steered PhotoPrism's own bundled SPA and were never read by mulimage's UI.

Also fixes the Library tree occasionally getting stuck on "Loading folders…"
by dropping gcTime:0 and gating the spinner on isLoading instead of isPending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 22:41:12 +02:00
parent ba5684d120
commit 634abc2a95
12 changed files with 513 additions and 542 deletions

View File

@@ -9,24 +9,30 @@
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, FolderOpen, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
cancelImport,
cancelIndex,
getConfig,
getErrors,
getSettings,
getIndexSubpath,
listFoldersUnderBase,
saveSettings,
startImport,
setIndexSubpath,
startIndex,
type ImportBody,
type IndexBody,
type PpFolder,
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/photoprism';
import { userBasePath } from '$lib/stores/session.svelte';
import {
prefs,
setIndexSubpathState,
toOriginalsPath
} from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
open: boolean;
@@ -36,7 +42,7 @@
const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');
// ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them
@@ -62,7 +68,6 @@
return {
...s,
index: s.index ?? {},
import: s.import ?? {},
stack: s.stack ?? {},
download: s.download ?? {}
};
@@ -94,17 +99,68 @@
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
// ── Index folder (per-user, server-side) ──────────────────────────────
// The originals-relative sub-folder, under the user's BasePath, that the
// whole app re-roots to (Library tree) and the reindex scopes to. Picked
// from the *full* BasePath tree (listFoldersUnderBase) so the user can
// choose any sub-folder — including ones outside the current root. Stored
// by the sidecar; mirrored into the `prefs` store so the sidebar reacts.
const subpathFoldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders-under-base'],
queryFn: listFoldersUnderBase,
enabled: open && activeTab === 'library'
}));
const subpathTree = $derived(
buildTree((subpathFoldersQuery.data ?? []).map((f) => f.Path))
);
// Hydrate the picker selection from the server pref when the dialog opens,
// so it reflects the current choice instead of the in-memory store alone.
const indexPrefQuery = createQuery<string>(() => ({
queryKey: ['prefs'],
queryFn: getIndexSubpath,
enabled: open
}));
// Local selection: '' = whole folder. Seeded from the store, then from the
// server pref once it loads.
let pickedSubpath = $state<string>(prefs.indexSubpath);
$effect(() => {
if (open && indexPrefQuery.data !== undefined) {
pickedSubpath = indexPrefQuery.data;
}
});
const saveSubpathMut = createMutation(() => ({
mutationFn: (sub: string) => setIndexSubpath(sub),
onSuccess: (saved) => {
setIndexSubpathState(saved);
qc.setQueryData(['prefs'], saved);
// Re-root the sidebar tree + grid: both are keyed on the effective
// library base, which just changed.
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
toast.success(saved === '' ? 'Indexing whole folder' : `Index folder: ${saved}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save index folder')
}));
// ── Index tab ─────────────────────────────────────────────────────────
// 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();
// Default the reindex path to the effective library root (BasePath +
// chosen index sub-path), so a manual run only rescans the user's working
// subtree. PhotoPrism's /index expects originals-relative paths with a
// leading slash; `'/'` means the whole library.
let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`,
path: '/' + toOriginalsPath('/'),
rescan: false,
cleanup: false
});
// SettingsDialog is mounted (open=false) before the index sub-path
// hydrates, so re-seed the manual-run path to the effective library root
// each time the dialog opens (and whenever the chosen root changes).
$effect(() => {
if (open) indexForm.path = '/' + toOriginalsPath('/');
});
const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
@@ -118,21 +174,6 @@
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Import tab ────────────────────────────────────────────────────────
let importForm = $state<ImportBody>({ path: '/', move: false, dest: '' });
const startImportMut = createMutation(() => ({
mutationFn: (b: ImportBody) => startImport(b),
onSuccess: (r) => toast.success(r.message || 'Import complete'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Import failed')
}));
const cancelImportMut = createMutation(() => ({
mutationFn: () => cancelImport(),
onSuccess: () => toast.success('Import canceled'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Logs tab ──────────────────────────────────────────────────────────
// Poll while the Logs tab is showing; pause otherwise so the dialog
// doesn't burn requests when the user is in another tab.
@@ -237,7 +278,7 @@
<Tabs.List
class="mb-3 flex gap-1 border-b border-border"
>
{#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
{#each ['library', 'index', 'logs', 'about'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
@@ -248,7 +289,73 @@
</Tabs.List>
<!-- Library — general settings -->
<Tabs.Content value="library" class="outline-none">
<Tabs.Content value="library" class="space-y-4 outline-none">
<!-- Index folder — the per-user sub-folder the Library tree
re-roots to and the reindex scopes to. Picked from the
full BasePath tree so any sub-folder is reachable. -->
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Index folder
</h3>
<p class="text-muted-foreground">
Pick the sub-folder PhotoPrism should treat as your library
root. The folder tree re-roots here and the reindex only scans
this subtree. Leave on “Whole folder” to use everything.
</p>
<div class="rounded-md border border-border bg-background p-2">
<div class="max-h-[180px] overflow-y-auto">
{#if subpathFoldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if subpathFoldersQuery.isError}
<EmptyState
size="compact"
tone="destructive"
icon={FolderOpen}
title="Could not load folders"
/>
{:else}
<!-- Whole-folder reset: '' is the "no sub-path" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedSubpath === ''}
class:text-primary-foreground={pickedSubpath === ''}
class:hover:bg-primary={pickedSubpath === ''}
onclick={() => (pickedSubpath = '')}
>
Whole folder
</button>
{#if (subpathFoldersQuery.data ?? []).length > 0}
<FolderTree
nodes={subpathTree}
onPick={(p) => (pickedSubpath = p)}
selectedPath={pickedSubpath}
readonly
/>
{/if}
{/if}
</div>
</div>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-[11px] text-muted-foreground">
Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath}
</span>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => saveSubpathMut.mutate(pickedSubpath)}
disabled={saveSubpathMut.isPending || pickedSubpath === prefs.indexSubpath}
>
{#if saveSubpathMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Set index folder
</button>
</div>
</section>
<div class="h-px bg-border"></div>
{#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError}
@@ -284,25 +391,6 @@
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Importer defaults
</h3>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.import!.move} />
Move (instead of copy) on import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Default destination subpath</span>
<input
type="text"
placeholder="e.g. 2026/05"
bind:value={draft.import!.dest}
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Stacks
@@ -411,34 +499,6 @@
</section>
{/if}
<!-- Features — PhotoPrism's gating bag. Render only the
keys actually present in the response (PP version
drift), labelled human-readably. -->
{#if draft.features && Object.keys(draft.features).length > 0}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<p class="text-muted-foreground">
Toggling a feature off hides it from PhotoPrism's own
UI and disables the underlying API surface.
</p>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.keys(draft.features).sort() as key (key)}
{#if typeof draft.features![key] === 'boolean'}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.features![key]}
/>
<span class="capitalize">{key}</span>
</label>
{/if}
{/each}
</div>
</section>
{/if}
</div>
<div class="mt-4 flex items-center justify-end gap-2">
@@ -511,58 +571,6 @@
</div>
</Tabs.Content>
<!-- Import — manual import run -->
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none">
<p class="text-muted-foreground">
Pulls files from the import folder into the library. With "move"
enabled, files are deleted from the import folder after a
successful import.
</p>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Source path</span>
<input
type="text"
bind:value={importForm.path}
placeholder="/"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={importForm.move} />
Move files (don't copy) after import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Destination subpath (optional)</span>
<input
type="text"
bind:value={importForm.dest}
placeholder="e.g. 2026/05"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
onclick={() => cancelImportMut.mutate()}
disabled={cancelImportMut.isPending || startImportMut.isPending}
>
Cancel current
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => startImportMut.mutate(importForm)}
disabled={startImportMut.isPending}
>
{#if startImportMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Start import
</button>
</div>
</Tabs.Content>
<!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
{#if configQuery.isPending}