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

@@ -26,6 +26,21 @@ type Mark struct {
// nothing surprising lands in the schema. // nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" } func (Mark) TableName() string { return "marks" }
// UserPref holds the per-user, server-side preferences PhotoPrism's account
// model has no slot for. Today that's just `IndexPath` — the originals-
// relative sub-folder (under the user's BasePath) the web client re-roots the
// Library tree to and scopes the reindex to. Empty string = "whole folder".
// Keyed by username so each user has independent prefs, matching `Mark`.
type UserPref struct {
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
IndexPath string `gorm:"size:1024;column:index_path" json:"indexPath"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
}
// TableName pins the table name (GORM would pluralise to `user_prefs` anyway,
// but pin it explicitly to stay consistent with Mark).
func (UserPref) TableName() string { return "user_prefs" }
// asJSON returns the wire shape clients expect — same flat object the // asJSON returns the wire shape clients expect — same flat object the
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders // Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
// as `{}` which the client treats as "no mark on this photo". // as `{}` which the client treats as "no mark on this photo".
@@ -56,7 +71,7 @@ func openDB(dsn string) (*gorm.DB, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := db.AutoMigrate(&Mark{}); err != nil { if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
return nil, err return nil, err
} }
return db, nil return db, nil

View File

@@ -9,10 +9,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
const quarantineDir = ".duplicates" const quarantineDir = ".duplicates"
@@ -36,17 +38,41 @@ type dupListPhoto struct {
Files []ppFile `json:"Files"` Files []ppFile `json:"Files"`
} }
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc { func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token := ctxToken(c) token := ctxToken(c)
start := time.Now() start := time.Now()
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
all, err := walkFiles(cfg.OriginalsRoot) // Scope the walk to the user's effective library root (BasePath +
// chosen index sub-path), same as the folders/timeline/reindex scope —
// otherwise a narrowed root would still surface every other user's
// files in the cross-folder duplicate scan. "" means whole library
// (today's admin-without-BasePath default).
root := effectiveLibraryRoot(c, db)
scanRoot := cfg.OriginalsRoot
if root != "" {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
return
}
scanRoot = abs
}
slog.Info("dup.scan starting", "root", scanRoot)
all, err := walkFiles(scanRoot)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
// scoped sub-path so RelPath stays originals-root-relative, matching
// what handleDupArchive (and the rest of the API) expects.
if root != "" {
for i := range all {
all[i].RelPath = root + "/" + all[i].RelPath
}
}
// Group by size first: byte-identical files necessarily share size, // Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip // so size-collision is a cheap O(N) prefilter that lets us skip
@@ -149,7 +175,7 @@ type dupArchiveErr struct {
Error string `json:"error"` Error string `json:"error"`
} }
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc { func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token := ctxToken(c) token := ctxToken(c)
var body dupArchiveBody var body dupArchiveBody
@@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return return
} }
// Authz: every path must live under the caller's effective library
// root. The scan above already only ever returns paths from there,
// but this endpoint takes paths straight from the request body, so a
// scoped (non-admin, or admin-with-sub-path) user could otherwise
// pass an arbitrary originals-relative path and archive (move) files
// outside their own folder.
root := effectiveLibraryRoot(c, db)
if root != "" {
for _, p := range body.Paths {
clean := strings.Trim(p, "/")
if clean != root && !strings.HasPrefix(clean, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
// Each archive batch lands in its own timestamped subdir so the // Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand // user can browse what was quarantined when (and recover by hand
// if they change their mind). // if they change their mind).

117
sidecar/handlers_prefs.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Per-user preferences the PhotoPrism account model can't hold. Currently a
// single field — the index sub-path the web client re-roots the Library tree
// to and scopes the reindex to. Stored in the sidecar's own DB keyed by
// username (see UserPref in db.go); never touches PhotoPrism's auth_users.
// prefsBody is the wire shape for GET responses and PUT requests alike.
type prefsBody struct {
IndexPath string `json:"indexPath"`
}
// loadUserPref reads the row for a user, returning a zero-value pref (empty
// IndexPath) when none exists yet — the "whole folder" default.
func loadUserPref(db *gorm.DB, userName string) (UserPref, error) {
var p UserPref
err := db.Where("user_name = ?", userName).First(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return UserPref{UserName: userName}, nil
}
return p, err
}
func handlePrefsGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
p, err := loadUserPref(db, ctxUserName(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, prefsBody{IndexPath: p.IndexPath})
}
}
// handlePrefsPut validates the requested index sub-path lives under the user's
// BasePath (an existing directory, no traversal) and upserts it. An empty
// string clears the sub-path back to "whole folder".
func handlePrefsPut(cfg *Config, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body prefsBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
// Normalise to originals-relative, no leading/trailing slashes —
// the same shape the web client and auth_users.base_path use.
sub := strings.Trim(strings.TrimSpace(body.IndexPath), "/")
if sub != "" {
// The sub-path is relative to the user's BasePath; resolve the
// combined originals-relative path and require it to be an
// existing directory inside the originals root. resolveUnderRoot
// already rejects traversal and symlink escapes.
base := strings.Trim(ctxBasePath(c), "/")
combined := sub
if base != "" {
combined = base + "/" + sub
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, combined, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index path: " + err.Error()})
return
}
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "index path is not a folder"})
return
}
}
userName := ctxUserName(c)
p := UserPref{UserName: userName, IndexPath: sub, UpdatedAt: time.Now().UTC()}
// Upsert: a clear (sub == "") persists an empty string rather than
// deleting the row, so the GET path stays a single code branch.
if err := db.Save(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, prefsBody{IndexPath: sub})
}
}
// effectiveLibraryRoot returns the requesting user's working library root,
// originals-relative with no leading/trailing slash: their BasePath narrowed
// by their chosen index sub-path (if any). Mirrors the web client's
// `userLibraryBase()` — handlers that walk the filesystem on a user's behalf
// (duplicate scan/archive) should scope to this instead of cfg.OriginalsRoot
// so a narrowed root also narrows what those handlers can see or touch.
// Returns "" for "whole library" (no BasePath and no sub-path set — today's
// admin default).
func effectiveLibraryRoot(c *gin.Context, db *gorm.DB) string {
base := strings.Trim(ctxBasePath(c), "/")
pref, err := loadUserPref(db, ctxUserName(c))
sub := ""
if err == nil {
sub = strings.Trim(pref.IndexPath, "/")
}
if sub == "" {
return base
}
if base == "" {
return sub
}
return base + "/" + sub
}

View File

@@ -81,6 +81,9 @@ func main() {
// under one group keeps the middleware wiring obvious. // under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp)) auth := r.Group("/api/sidecar", requireSession(pp))
{ {
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db))
@@ -97,8 +100,8 @@ func main() {
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
// User-scoped proxies — require PpDSN connection. // User-scoped proxies — require PpDSN connection.
if ppDb != nil { if ppDb != nil {

View File

@@ -28,6 +28,7 @@
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { userLibraryBase } from '$lib/stores/session.svelte';
import StackGroupCard from './StackGroupCard.svelte'; import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte'; import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -50,14 +51,14 @@
// "Rescan filesystem" button invalidates to force a re-scan after // "Rescan filesystem" button invalidates to force a re-scan after
// the user has moved files around. // the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({ const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: activeTab === 'cross-folder', enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
function rescan() { function rescan() {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] }); void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
} }
$effect(() => { $effect(() => {

View File

@@ -1,24 +1,20 @@
<!-- <!--
General app preferences. The UI tab owns the SvelteKit shell's General app preferences. Two tabs: the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs light/dark/system theme (mode-watcher) and the signed-in user's account
PhotoPrism's /settings exposes. Search and Maps follow the same (identity + password change).
pattern — server prefs round-trip via /api/v1/settings.
The Library admin dialog and this one share the ['settings'] cache, PhotoPrism's own per-user UI/search/maps knobs used to live here too, but
so saves from either invalidate the other. they only steer PhotoPrism's bundled SPA — which mulimage's users never
see — so they were removed. mulimage's own view prefs live in the view
store; the library admin knobs live under Folders → ⚙ (SettingsDialog).
--> -->
<script lang="ts"> <script lang="ts">
import { Dialog, Tabs } from 'bits-ui'; import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation } from '@tanstack/svelte-query';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte'; import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import { import { setUserPassword } from '$lib/services/photoprism';
getSettings,
saveSettings,
setUserPassword,
type PpSettings
} from '$lib/services/photoprism';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
interface Props { interface Props {
@@ -27,9 +23,7 @@
} }
let { open, onClose }: Props = $props(); let { open, onClose }: Props = $props();
const qc = useQueryClient(); let activeTab = $state<'ui' | 'account'>('ui');
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
// ── Account tab — password change ───────────────────────────────────── // ── Account tab — password change ─────────────────────────────────────
let pwOld = $state(''); let pwOld = $state('');
@@ -59,107 +53,6 @@
{ value: 'system', label: 'System', Icon: Monitor } { value: 'system', label: 'System', Icon: Monitor }
] as const; ] as const;
// PhotoPrism palette names from its built-in themes. Any value
// outside this list is preserved verbatim (see `withCurrent`).
const ppThemes = [
'default',
'abyss',
'gemstone',
'grayscale',
'lavender',
'legacy',
'neon',
'onyx',
'raspberry',
'shadow',
'yellowstone'
];
// IETF subtags PhotoPrism ships translations for. Extend without
// fear — `withCurrent` keeps unknown values visible.
const ppLanguages = [
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
];
const ppStartPages = [
'default',
'browse',
'albums',
'calendar',
'moments',
'people',
'places',
'labels',
'states',
'library'
];
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
// Returns `opts` with `current` prepended if it's set and not
// already in the list — so e.g. an experimental theme name in the
// server response shows up selected and editable instead of
// silently being overwritten by the dropdown's default.
function withCurrent(opts: string[], current?: string): string[] {
if (!current) return opts;
return opts.includes(current) ? opts : [current, ...opts];
}
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
/**
* Some PhotoPrism deployments return `/settings` without the
* `ui` / `search` / `maps` keys (older versions, custom edits to
* settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
* non-null-asserts those sub-objects — when they're missing the
* assertion lies and the bind getter throws on the next tick. Force
* the shape on every clone so every binding has a real object to
* write into, and so `draft.ui` is never null while `draft` is non-
* null (template gates only check `draft`).
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
ui: s.ui ?? {},
search: s.search ?? {},
maps: s.maps ?? {}
};
}
let draft = $state<PpSettings | null>(null);
// Re-clone on each open so reopening the dialog shows the freshest
// server state. Eagerly nulling on close used to introduce a window
// where Dialog's exit animation kept the form mounted while draft
// was already null — and bind:value getters read null, triggering
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
// open instead avoids that race entirely.
$effect(() => {
if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = normalize(structuredClone(next));
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
const selectClass = const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring'; 'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script> </script>
@@ -198,7 +91,7 @@
<Tabs.Root bind:value={activeTab}> <Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border"> <Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps', 'account'] as const as t (t)} {#each ['ui', 'account'] as const as t (t)}
<Tabs.Trigger <Tabs.Trigger
value={t} 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" 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"
@@ -208,8 +101,7 @@
{/each} {/each}
</Tabs.List> </Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the <!-- UI — local app theme (mode-watcher). Persists itself; no Save. -->
PhotoPrism per-user UI knobs that go to /settings. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none"> <Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2"> <section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -239,126 +131,10 @@
Light/dark for this app. Persists locally; no Save needed. Light/dark for this app. Persists locally; no Save needed.
</p> </p>
</section> </section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading server settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load server settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
<select bind:value={draft.ui!.theme} class={selectClass}>
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Language</span>
<select bind:value={draft.ui!.language} class={selectClass}>
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Time zone</span>
<!-- IANA tz list is ~400 entries, browser support varies; use
a datalist so we get autocomplete without spamming a
gigantic <select>. "Local" is PhotoPrism's special
"follow system" sentinel. -->
<input
type="text"
list="general-tz-list"
placeholder="Local"
bind:value={draft.ui!.timeZone}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Start page</span>
<select bind:value={draft.ui!.startPage} class={selectClass}>
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
Always show scrollbars
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.zoom} />
Allow image zoom
</label>
</section>
{/if}
</Tabs.Content> </Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'} <!-- Account — reads from the session store and round-trips its own
<Tabs.Content value={activeTab} class="outline-none"> password mutation. -->
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
</Tabs.Content>
{:else if draft}
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.listView} />
Default to list view
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showTitles} />
Show titles
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
Show captions
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Batch size (-1 = server default)
</span>
<input
type="number"
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span>
<select bind:value={draft.maps!.style} class={selectClass}>
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Animation duration (ms, 0 = off)
</span>
<input
type="number"
bind:value={draft.maps!.animate}
class={selectClass}
/>
</label>
</Tabs.Content>
{/if}
<!-- Account — independent of /settings; reads from the session
store and round-trips its own mutation. -->
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none"> <Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2"> <section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -448,59 +224,6 @@
</form> </form>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
"Local" sentinel when the browser can't enumerate the
IANA list (older Safari, etc.). -->
<datalist id="general-tz-list">
<option value="Local"></option>
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
</datalist>
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save.
Account tab has its own Update-password button, so skip. -->
{#if draft && activeTab !== 'account'}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<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={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
{/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
<script lang="ts" module>
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
// 15.3 and below) return undefined here. The component handles that
// by simply showing only the "Local" sentinel in the datalist.
export function tzOptions(): string[] {
const fn = (Intl as unknown as {
supportedValuesOf?: (k: string) => string[];
}).supportedValuesOf;
if (typeof fn !== 'function') return [];
try {
return fn('timeZone');
} catch {
return [];
}
}
</script>

View File

@@ -12,6 +12,7 @@
deleteFolder, deleteFolder,
deleteHeap, deleteHeap,
duplicateHeap, duplicateHeap,
getIndexSubpath,
heapDownloadUrl, heapDownloadUrl,
listFolders, listFolders,
listHeaps, listHeaps,
@@ -45,8 +46,11 @@
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { import {
isAuthenticated, isAuthenticated,
prefs,
session, session,
setIndexSubpathState,
userBasePath, userBasePath,
userLibraryBase,
toOriginalsPath, toOriginalsPath,
toUserPath toUserPath
} from '$lib/stores/session.svelte'; } from '$lib/stores/session.svelte';
@@ -84,25 +88,48 @@
enabled: isAuthenticated() enabled: isAuthenticated()
})); }));
// Keyed on the effective library base (BasePath + chosen index sub-path)
// so re-rooting refetches, and so the post-bootstrap identity change forces
// a fresh fetch instead of leaving the query wedged in pending/idle (the
// old `gcTime: 0` + `enabled` toggle could strand it there on first paint).
const foldersQuery = createQuery<PpFolder[]>(() => ({ const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'], queryKey: ['folders', userLibraryBase()],
queryFn: listFolders, queryFn: listFolders,
enabled: isAuthenticated(), enabled: isAuthenticated(),
gcTime: 0 staleTime: 30_000,
retry: 2,
refetchOnMount: 'always'
})); }));
// Hydrate the per-user index sub-path into the session store on load so the
// Library tree re-roots to it without waiting for the settings dialog to be
// opened. Shares the ['prefs'] key with SettingsDialog's setter.
const prefsQuery = createQuery<string>(() => ({
queryKey: ['prefs'],
queryFn: getIndexSubpath,
enabled: isAuthenticated(),
staleTime: 5 * 60_000
}));
$effect(() => {
if (prefsQuery.data !== undefined) setIndexSubpathState(prefsQuery.data);
});
// Stacks + cross-folder duplicate caches are warmed here so the // Stacks + cross-folder duplicate caches are warmed here so the
// /duplicates view (and its review tab strip) hits a warm cache. The // /duplicates view (and its review tab strip) hits a warm cache. The
// sidebar only observes these — cross-folder is an O(disk) scan, so it // sidebar only observes these — cross-folder is an O(disk) scan, so it
// stays enabled:false and the duplicates page populates it on first visit. // stays enabled:false and the duplicates page populates it on first visit.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates', userBasePath()], queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userBasePath()), queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 60_000 staleTime: 60_000
})); }));
// The cross-folder scan is server-scoped to the caller's effective
// library root (sidecar reads BasePath + the stored index sub-path
// itself), but the query is still keyed on userLibraryBase() so changing
// the index folder invalidates the stale, differently-scoped result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({ const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: false, enabled: false,
staleTime: 5 * 60_000 staleTime: 5 * 60_000
@@ -244,6 +271,17 @@
session.user?.DisplayName?.trim() || session.user?.Name || '/' session.user?.DisplayName?.trim() || session.user?.Name || '/'
); );
// When the user has narrowed their library to an index sub-folder, the
// root row stands for that sub-folder — surface its leaf name so it's
// obvious the tree is re-rooted rather than showing the whole account.
const rootSubLabel = $derived(
prefs.indexSubpath === '' ? '' : (prefs.indexSubpath.split('/').pop() ?? '')
);
const rootTitle = $derived.by(() => {
const base = userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`;
return prefs.indexSubpath === '' ? base : `${base}${prefs.indexSubpath}`;
});
async function onSignOut() { async function onSignOut() {
await logout(); await logout();
await goto('/login', { replaceState: true }); await goto('/login', { replaceState: true });
@@ -320,7 +358,9 @@
if (indexer.active) return; if (indexer.active) return;
const tid = toast.loading('Starting reindex…'); const tid = toast.loading('Starting reindex…');
try { try {
await startIndex({ path: '/', rescan: false, cleanup: false }); // Scope the one-click reindex to the effective library root
// (BasePath + chosen index sub-path) rather than the whole library.
await startIndex({ path: '/' + toOriginalsPath('/'), rescan: false, cleanup: false });
toast.success('Reindex started — new files will appear as theyre found', { id: tid }); toast.success('Reindex started — new files will appear as theyre found', { id: tid });
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid }); toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
@@ -550,11 +590,16 @@
{/if} {/if}
<button <button
type="button" type="button"
class="flex min-w-0 flex-1 items-center pl-1 text-left" class="flex min-w-0 flex-1 items-center gap-1 pl-1 text-left"
onclick={() => pickFolder('/')} onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`} title={rootTitle}
> >
<span class="truncate">{rootLabel}</span> <span class="truncate">{rootLabel}</span>
{#if rootSubLabel}
<span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
/ {rootSubLabel}
</span>
{/if}
</button> </button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself <!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted can't be renamed or deleted, so those entries are omitted
@@ -572,7 +617,7 @@
</KebabMenu> </KebabMenu>
</div> </div>
</div> </div>
{#if foldersQuery.isPending} {#if foldersQuery.isLoading}
<InlineLoader size="sm" label="Loading folders…" /> <InlineLoader size="sm" label="Loading folders…" />
{:else if foldersQuery.isError} {:else if foldersQuery.isError}
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." /> <EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />

View File

@@ -32,7 +32,7 @@
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte'; import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte'; import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte'; import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
@@ -41,7 +41,7 @@
// Reuse the same folders cache the sidebar uses — same key so we share the // Reuse the same folders cache the sidebar uses — same key so we share the
// in-flight request, and the picker invalidates it on success. // in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({ const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'], queryKey: ['folders', userLibraryBase()],
queryFn: listFolders, queryFn: listFolders,
enabled: isAuthenticated() enabled: isAuthenticated()
})); }));

View File

@@ -9,24 +9,30 @@
import { Dialog, Tabs } from 'bits-ui'; import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; 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 { EmptyState, InlineLoader } from '$lib/components/feedback';
import { import {
cancelImport,
cancelIndex, cancelIndex,
getConfig, getConfig,
getErrors, getErrors,
getSettings, getSettings,
getIndexSubpath,
listFoldersUnderBase,
saveSettings, saveSettings,
startImport, setIndexSubpath,
startIndex, startIndex,
type ImportBody,
type IndexBody, type IndexBody,
type PpFolder,
type PpLogEntry, type PpLogEntry,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/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 { interface Props {
open: boolean; open: boolean;
@@ -36,7 +42,7 @@
const qc = useQueryClient(); const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library'); let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');
// ── Library tab ─────────────────────────────────────────────────────── // ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them // Pull settings only while the dialog is open so we don't keep them
@@ -62,7 +68,6 @@
return { return {
...s, ...s,
index: s.index ?? {}, index: s.index ?? {},
import: s.import ?? {},
stack: s.stack ?? {}, stack: s.stack ?? {},
download: s.download ?? {} download: s.download ?? {}
}; };
@@ -94,17 +99,68 @@
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data)); 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 ───────────────────────────────────────────────────────── // ── Index tab ─────────────────────────────────────────────────────────
// Default the reindex path to the user's BasePath when scoping is on, // Default the reindex path to the effective library root (BasePath +
// so non-admins (and admins-with-BasePath) only rescan their own // chosen index sub-path), so a manual run only rescans the user's working
// subtree. PhotoPrism's /index expects originals-relative paths with // subtree. PhotoPrism's /index expects originals-relative paths with a
// a leading slash; `'/'` means the whole library. // leading slash; `'/'` means the whole library.
const _bp = userBasePath();
let indexForm = $state<IndexBody>({ let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`, path: '/' + toOriginalsPath('/'),
rescan: false, rescan: false,
cleanup: 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(() => ({ const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b), mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'), onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
@@ -118,21 +174,6 @@
toast.error(err instanceof Error ? err.message : 'Cancel failed') 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 ────────────────────────────────────────────────────────── // ── Logs tab ──────────────────────────────────────────────────────────
// Poll while the Logs tab is showing; pause otherwise so the dialog // Poll while the Logs tab is showing; pause otherwise so the dialog
// doesn't burn requests when the user is in another tab. // doesn't burn requests when the user is in another tab.
@@ -237,7 +278,7 @@
<Tabs.List <Tabs.List
class="mb-3 flex gap-1 border-b border-border" 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 <Tabs.Trigger
value={t} 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" 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> </Tabs.List>
<!-- Library — general settings --> <!-- 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} {#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p> <p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError} {:else if settingsQuery.isError}
@@ -284,25 +391,6 @@
</label> </label>
</section> </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"> <section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Stacks Stacks
@@ -411,34 +499,6 @@
</section> </section>
{/if} {/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>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
@@ -511,58 +571,6 @@
</div> </div>
</Tabs.Content> </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 --> <!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none"> <Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
{#if configQuery.isPending} {#if configQuery.isPending}

View File

@@ -7,7 +7,8 @@ import {
session, session,
toOriginalsPath, toOriginalsPath,
toUserPath, toUserPath,
userBasePath userBasePath,
userLibraryBase
} from '$lib/stores/session.svelte'; } from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism'; import { primaryFile } from '$lib/types/photoprism';
import type { import type {
@@ -503,23 +504,45 @@ export interface PpFolder {
* row itself is dropped — the sidebar synthesises the root entry. When * row itself is dropped — the sidebar synthesises the root entry. When
* BasePath is empty (today's admin default) this is a no-op. * BasePath is empty (today's admin default) this is a no-op.
*/ */
export async function listFolders(): Promise<PpFolder[]> { async function fetchFolders(): Promise<PpFolder[]> {
const { data } = await sidecar.get<{ folders?: PpFolder[] }>( const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders', '/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } } { params: { recursive: true, uncached: true, files: false } }
); );
const bp = userBasePath(); return data.folders ?? [];
// Sidecar already filters by BasePath; the frontend still applies the }
// filter + path rewrite as a safety net for admin (bp="") and for any
// folders that might have slipped through. /**
const folders = data.folders ?? []; * Filter a flat folder list to those at/under `base` (server-absolute,
if (bp === '') return folders; * originals-relative) and rewrite each `Path` to be `base`-relative, dropping
* the `base` row itself. `base === ''` (whole library) is a no-op. Sidecar
* already filters by BasePath; this is the frontend's safety net + the
* narrowing to the chosen index sub-path.
*/
function scopeFolders(folders: PpFolder[], base: string): PpFolder[] {
if (base === '') return folders;
return folders return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/')) .filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) })) .map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
.filter((f) => f.Path !== ''); .filter((f) => f.Path !== '');
} }
export async function listFolders(): Promise<PpFolder[]> {
// Scoped to the *effective* library root (BasePath + chosen index
// sub-path) so the sidebar tree re-roots to whatever the user picked.
return scopeFolders(await fetchFolders(), userLibraryBase());
}
/**
* Like `listFolders` but scoped to the user's *whole* BasePath, ignoring the
* chosen index sub-path. The index-folder picker uses this so the user can
* choose any sub-folder of their library as a new root — including ones
* outside the current sub-path.
*/
export async function listFoldersUnderBase(): Promise<PpFolder[]> {
return scopeFolders(await fetchFolders(), userBasePath());
}
/** /**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders` * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has * endpoint reports `FileCount: 0` even when populated, so the count has
@@ -1077,26 +1100,17 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// ── Settings / Admin ───────────────────────────────────────────────────────── // ── Settings / Admin ─────────────────────────────────────────────────────────
// //
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog // Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer // (Library / Index / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint // PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object. // merges server-side, so it's safe to round-trip an incomplete object.
// PhotoPrism's /settings payload. muleimage only drives the indexer/stack/
// download knobs from its own UI — the `ui`/`search`/`maps`/`import`/`features`
// blocks PhotoPrism also returns only steer PhotoPrism's own SPA (which our
// users never see), so they're intentionally omitted here and never surfaced.
// The `[k: string]` index signature means an unknown round-tripped block is
// preserved on save without us having to model it.
export interface PpSettings { export interface PpSettings {
ui?: {
theme?: string;
language?: string;
timeZone?: string;
startPage?: string;
scrollbar?: boolean;
zoom?: boolean;
};
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: { index?: {
path?: string; path?: string;
convert?: boolean; convert?: boolean;
@@ -1106,7 +1120,6 @@ export interface PpSettings {
skipRaw?: boolean; skipRaw?: boolean;
skipHidden?: boolean; skipHidden?: boolean;
}; };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean }; stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: { download?: {
name?: string; name?: string;
@@ -1117,42 +1130,27 @@ export interface PpSettings {
crc32?: boolean; crc32?: boolean;
sha1?: boolean; sha1?: boolean;
}; };
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
};
[k: string]: unknown; [k: string]: unknown;
} }
// ── Per-user prefs (sidecar) ──────────────────────────────────────────────────
//
// The index sub-path: an originals-relative folder under the user's BasePath
// that re-roots the Library tree and scopes the reindex. Stored server-side by
// the sidecar, keyed by username. Empty string = "whole folder".
export async function getIndexSubpath(): Promise<string> {
const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function setIndexSubpath(indexPath: string): Promise<string> {
const data = (await callSidecar('PUT', '/prefs', {
indexPath: indexPath.replace(/^\/+|\/+$/g, '')
})) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function getSettings(): Promise<PpSettings> { export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings'); const { data } = await http.get<PpSettings>('/settings');
return data; return data;
@@ -1183,26 +1181,6 @@ export async function cancelIndex(): Promise<void> {
await http.delete('/index'); await http.delete('/index');
} }
export interface ImportBody {
path?: string;
move?: boolean;
dest?: string;
}
export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/import', {
path: '/',
move: false,
dest: '',
...body
});
return data;
}
export async function cancelImport(): Promise<void> {
await http.delete('/import');
}
export interface PpLogEntry { export interface PpLogEntry {
Time: string; Time: string;
Level: string; Level: string;

View File

@@ -57,6 +57,10 @@ export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): voi
// (Hit this with the `test` user seeing the admin's library counts // (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.) // in the left sidebar.)
queryClient.clear(); queryClient.clear();
// The index sub-path is per-user; drop the prior identity's value so the
// app re-roots to the new user's whole folder until the ['prefs'] query
// rehydrates it from the sidecar.
prefs.indexSubpath = '';
session.id = resp.id; session.id = resp.id;
session.accessToken = resp.access_token; session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? ''; session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
@@ -71,6 +75,7 @@ export function clearSession(): void {
session.previewToken = null; session.previewToken = null;
session.downloadToken = null; session.downloadToken = null;
session.user = null; session.user = null;
prefs.indexSubpath = '';
if (browser) localStorage.removeItem(STORAGE_KEY); if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user // Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the // who logs in (or the login screen itself) doesn't render with the
@@ -163,43 +168,73 @@ export function videoUrl(hash: string, format = 'avc'): string {
/** /**
* The signed-in user's library root, originals-relative, no leading/trailing * The signed-in user's library root, originals-relative, no leading/trailing
* slash. `""` means "whole library" — used today by admin accounts whose * slash. `""` means "whole library" — used today by admin accounts whose
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place * BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter, * as set on their PhotoPrism account; the working library root the rest of
* folder counts, heap convert) so each user sees only their own subtree. * the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
*/ */
export function userBasePath(): string { export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, ''); return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
} }
/**
* Per-user "index sub-path": a folder *under* the user's BasePath that they've
* chosen as their working library root. Stored server-side by the sidecar
* (keyed by username) and hydrated into this reactive state at startup via the
* `['prefs']` query. Empty string = "whole folder" (no narrowing). Normalized
* to no leading/trailing slash.
*/
export const prefs = $state<{ indexSubpath: string }>({ indexSubpath: '' });
export function setIndexSubpathState(sub: string): void {
prefs.indexSubpath = (sub ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* The effective working library root, originals-relative, no leading/trailing
* slash: the user's BasePath narrowed by their chosen index sub-path. This is
* the single point the whole app re-roots through — `toOriginalsPath` /
* `toUserPath` (and thus the sidebar tree, timeline `path:` filter, folder
* counts, folder CRUD, reindex) all derive from it. When both are empty it's
* `""` (whole library), matching the prior BasePath-only behavior.
*/
export function userLibraryBase(): string {
const bp = userBasePath();
const sub = prefs.indexSubpath;
if (sub === '') return bp;
return bp === '' ? sub : `${bp}/${sub}`;
}
/** /**
* Translate a user-relative path (what the sidebar and URL deal in) to a * Translate a user-relative path (what the sidebar and URL deal in) to a
* server-absolute, originals-relative path (what PhotoPrism's `path:` * server-absolute, originals-relative path (what PhotoPrism's `path:`
* operator and the sidecar's filesystem ops want). * operator and the sidecar's filesystem ops want). Relative to the effective
* library root (`userLibraryBase()`), so the chosen index sub-path is folded
* in automatically.
* *
* "" or "/" → BasePath (user's root) * "" or "/" → libraryBase (user's working root)
* "2024/01" → "<basePath>/2024/01" * "2024/01" → "<libraryBase>/2024/01"
* null → "" (caller decides to omit the filter entirely) * null → "" (caller decides to omit the filter entirely)
*/ */
export function toOriginalsPath(uiPath: string | null): string { export function toOriginalsPath(uiPath: string | null): string {
if (uiPath === null) return ''; if (uiPath === null) return '';
const bp = userBasePath(); const base = userLibraryBase();
const rel = uiPath.replace(/^\/+|\/+$/g, ''); const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp; if (rel === '') return base;
return bp === '' ? rel : `${bp}/${rel}`; return base === '' ? rel : `${base}/${rel}`;
} }
/** /**
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the * Inverse of `toOriginalsPath` — strips the effective library-root prefix so
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that * the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the BasePath collapse to `""` (the user's root sentinel). * are equal to the root collapse to `""` (the user's root sentinel). Paths
* Paths outside the BasePath are returned as-is, but callers should * outside the root are returned as-is, but callers should already have
* already have filtered those out via `listFolders`'s post-filter. * filtered those out via `listFolders`'s post-filter.
*/ */
export function toUserPath(serverPath: string): string { export function toUserPath(serverPath: string): string {
const bp = userBasePath(); const base = userLibraryBase();
const sp = serverPath.replace(/^\/+|\/+$/g, ''); const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp; if (base === '') return sp;
if (sp === bp) return ''; if (sp === base) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1); if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
return sp; return sp;
} }

View File

@@ -30,7 +30,7 @@
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated, userBasePath } from '$lib/stores/session.svelte'; import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte'; import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte'; import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import { import {
@@ -85,13 +85,16 @@
// observes its cache (enabled:false) and DuplicatesView is what // observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active. // triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates', userBasePath()], queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userBasePath()), queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 30_000 staleTime: 30_000
})); }));
// Scope is enforced server-side (sidecar reads the caller's BasePath +
// stored index sub-path), but key on userLibraryBase() so switching the
// index folder doesn't show a stale, differently-scoped cached result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({ const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: false, enabled: false,
staleTime: 5 * 60_000 staleTime: 5 * 60_000