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.
|
// 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
|
||||||
|
|||||||
@@ -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
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.
|
// 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 {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 they’re found', { id: tid });
|
toast.success('Reindex started — new files will appear as they’re 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." />
|
||||||
|
|||||||
@@ -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()
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user