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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user