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:
@@ -26,6 +26,21 @@ type Mark struct {
|
||||
// nothing surprising lands in the schema.
|
||||
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
|
||||
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&Mark{}); err != nil {
|
||||
if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const quarantineDir = ".duplicates"
|
||||
@@ -36,17 +38,41 @@ type dupListPhoto struct {
|
||||
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) {
|
||||
token := ctxToken(c)
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
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,
|
||||
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||||
@@ -149,7 +175,7 @@ type dupArchiveErr struct {
|
||||
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) {
|
||||
token := ctxToken(c)
|
||||
var body dupArchiveBody
|
||||
@@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
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
|
||||
// user can browse what was quarantined when (and recover by hand
|
||||
// if they change their mind).
|
||||
|
||||
117
sidecar/handlers_prefs.go
Normal file
117
sidecar/handlers_prefs.go
Normal 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
|
||||
}
|
||||
@@ -81,6 +81,9 @@ func main() {
|
||||
// under one group keeps the middleware wiring obvious.
|
||||
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/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
@@ -97,8 +100,8 @@ func main() {
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import { userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import StackGroupCard from './StackGroupCard.svelte';
|
||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
@@ -50,14 +51,14 @@
|
||||
// "Rescan filesystem" button invalidates to force a re-scan after
|
||||
// the user has moved files around.
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: activeTab === 'cross-folder',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
function rescan() {
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<!--
|
||||
General app preferences. The UI tab owns the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||
PhotoPrism's /settings exposes. Search and Maps follow the same
|
||||
pattern — server prefs round-trip via /api/v1/settings.
|
||||
General app preferences. Two tabs: the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) and the signed-in user's account
|
||||
(identity + password change).
|
||||
|
||||
The Library admin dialog and this one share the ['settings'] cache,
|
||||
so saves from either invalidate the other.
|
||||
PhotoPrism's own per-user UI/search/maps knobs used to live here too, but
|
||||
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">
|
||||
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 { toast } from 'svelte-sonner';
|
||||
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
setUserPassword,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import { setUserPassword } from '$lib/services/photoprism';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -27,9 +23,7 @@
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
|
||||
let activeTab = $state<'ui' | 'account'>('ui');
|
||||
|
||||
// ── Account tab — password change ─────────────────────────────────────
|
||||
let pwOld = $state('');
|
||||
@@ -59,107 +53,6 @@
|
||||
{ value: 'system', label: 'System', Icon: Monitor }
|
||||
] 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 =
|
||||
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
@@ -198,7 +91,7 @@
|
||||
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<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
|
||||
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"
|
||||
@@ -208,8 +101,7 @@
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- UI — local app theme (mode-watcher) on top, then the
|
||||
PhotoPrism per-user UI knobs that go to /settings. -->
|
||||
<!-- UI — local app theme (mode-watcher). Persists itself; no Save. -->
|
||||
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<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.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<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. -->
|
||||
<!-- Account — reads from the session store and round-trips its own
|
||||
password mutation. -->
|
||||
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
@@ -448,59 +224,6 @@
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
</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.Portal>
|
||||
</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>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getIndexSubpath,
|
||||
heapDownloadUrl,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
@@ -45,8 +46,11 @@
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
isAuthenticated,
|
||||
prefs,
|
||||
session,
|
||||
setIndexSubpathState,
|
||||
userBasePath,
|
||||
userLibraryBase,
|
||||
toOriginalsPath,
|
||||
toUserPath
|
||||
} from '$lib/stores/session.svelte';
|
||||
@@ -84,25 +88,48 @@
|
||||
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[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryKey: ['folders', userLibraryBase()],
|
||||
queryFn: listFolders,
|
||||
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
|
||||
// /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
|
||||
// stays enabled:false and the duplicates page populates it on first visit.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates', userBasePath()],
|
||||
queryFn: () => listDuplicateGroups(userBasePath()),
|
||||
queryKey: ['duplicates', userLibraryBase()],
|
||||
queryFn: () => listDuplicateGroups(userLibraryBase()),
|
||||
enabled: isAuthenticated(),
|
||||
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>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
@@ -244,6 +271,17 @@
|
||||
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() {
|
||||
await logout();
|
||||
await goto('/login', { replaceState: true });
|
||||
@@ -320,7 +358,9 @@
|
||||
if (indexer.active) return;
|
||||
const tid = toast.loading('Starting reindex…');
|
||||
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 they’re found', { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
|
||||
@@ -550,11 +590,16 @@
|
||||
{/if}
|
||||
<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('/')}
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
title={rootTitle}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if rootSubLabel}
|
||||
<span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
|
||||
/ {rootSubLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
@@ -572,7 +617,7 @@
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
{#if foldersQuery.isLoading}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if foldersQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
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 FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
// Reuse the same folders cache the sidebar uses — same key so we share the
|
||||
// in-flight request, and the picker invalidates it on success.
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryKey: ['folders', userLibraryBase()],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
session,
|
||||
toOriginalsPath,
|
||||
toUserPath,
|
||||
userBasePath
|
||||
userBasePath,
|
||||
userLibraryBase
|
||||
} from '$lib/stores/session.svelte';
|
||||
import { primaryFile } from '$lib/types/photoprism';
|
||||
import type {
|
||||
@@ -503,23 +504,45 @@ export interface PpFolder {
|
||||
* row itself is dropped — the sidebar synthesises the root entry. When
|
||||
* BasePath is empty (today's admin default) this is a no-op.
|
||||
*/
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
async function fetchFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
// 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 ?? [];
|
||||
if (bp === '') return folders;
|
||||
return data.folders ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a flat folder list to those at/under `base` (server-absolute,
|
||||
* 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
|
||||
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
|
||||
.filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
|
||||
.map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
|
||||
.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`
|
||||
* 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 ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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
|
||||
// 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 {
|
||||
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?: {
|
||||
path?: string;
|
||||
convert?: boolean;
|
||||
@@ -1106,7 +1120,6 @@ export interface PpSettings {
|
||||
skipRaw?: boolean;
|
||||
skipHidden?: boolean;
|
||||
};
|
||||
import?: { path?: string; move?: boolean; dest?: string };
|
||||
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
||||
download?: {
|
||||
name?: string;
|
||||
@@ -1117,42 +1130,27 @@ export interface PpSettings {
|
||||
crc32?: 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;
|
||||
}
|
||||
|
||||
// ── 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> {
|
||||
const { data } = await http.get<PpSettings>('/settings');
|
||||
return data;
|
||||
@@ -1183,26 +1181,6 @@ export async function cancelIndex(): Promise<void> {
|
||||
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 {
|
||||
Time: string;
|
||||
Level: string;
|
||||
|
||||
@@ -57,6 +57,10 @@ export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): voi
|
||||
// (Hit this with the `test` user seeing the admin's library counts
|
||||
// in the left sidebar.)
|
||||
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.accessToken = resp.access_token;
|
||||
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
|
||||
@@ -71,6 +75,7 @@ export function clearSession(): void {
|
||||
session.previewToken = null;
|
||||
session.downloadToken = null;
|
||||
session.user = null;
|
||||
prefs.indexSubpath = '';
|
||||
if (browser) localStorage.removeItem(STORAGE_KEY);
|
||||
// Same reasoning as adoptSession — wipe the cache so the next user
|
||||
// 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
|
||||
* slash. `""` means "whole library" — used today by admin accounts whose
|
||||
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
|
||||
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
|
||||
* folder counts, heap convert) so each user sees only their own subtree.
|
||||
* BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
|
||||
* as set on their PhotoPrism account; the working library root the rest of
|
||||
* the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
|
||||
*/
|
||||
export function userBasePath(): string {
|
||||
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
|
||||
* 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)
|
||||
* "2024/01" → "<basePath>/2024/01"
|
||||
* "" or "/" → libraryBase (user's working root)
|
||||
* "2024/01" → "<libraryBase>/2024/01"
|
||||
* null → "" (caller decides to omit the filter entirely)
|
||||
*/
|
||||
export function toOriginalsPath(uiPath: string | null): string {
|
||||
if (uiPath === null) return '';
|
||||
const bp = userBasePath();
|
||||
const base = userLibraryBase();
|
||||
const rel = uiPath.replace(/^\/+|\/+$/g, '');
|
||||
if (rel === '') return bp;
|
||||
return bp === '' ? rel : `${bp}/${rel}`;
|
||||
if (rel === '') return base;
|
||||
return base === '' ? rel : `${base}/${rel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so 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).
|
||||
* Paths outside the BasePath are returned as-is, but callers should
|
||||
* already have filtered those out via `listFolders`'s post-filter.
|
||||
* Inverse of `toOriginalsPath` — strips the effective library-root prefix so
|
||||
* the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
|
||||
* are equal to the root collapse to `""` (the user's root sentinel). Paths
|
||||
* outside the root are returned as-is, but callers should already have
|
||||
* filtered those out via `listFolders`'s post-filter.
|
||||
*/
|
||||
export function toUserPath(serverPath: string): string {
|
||||
const bp = userBasePath();
|
||||
const base = userLibraryBase();
|
||||
const sp = serverPath.replace(/^\/+|\/+$/g, '');
|
||||
if (bp === '') return sp;
|
||||
if (sp === bp) return '';
|
||||
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
|
||||
if (base === '') return sp;
|
||||
if (sp === base) return '';
|
||||
if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
|
||||
return sp;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} 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 { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
@@ -85,13 +85,16 @@
|
||||
// observes its cache (enabled:false) and DuplicatesView is what
|
||||
// triggers the actual scan when its tab is active.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates', userBasePath()],
|
||||
queryFn: () => listDuplicateGroups(userBasePath()),
|
||||
queryKey: ['duplicates', userLibraryBase()],
|
||||
queryFn: () => listDuplicateGroups(userLibraryBase()),
|
||||
enabled: isAuthenticated(),
|
||||
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>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
|
||||
Reference in New Issue
Block a user