feat(sidebar): library + general settings dialogs, sidebar footer, alignment polish

- Add a Settings cog to the Folders header that opens a tabbed library
  admin dialog (Library / Index / Import / Logs) wrapping PhotoPrism's
  /api/v1 settings, index, import and errors endpoints.
- Add a sticky footer to the left sidebar with the signed-in user's
  display name plus quick-toggle theme, general-settings cog (separate
  dialog for app prefs), and sign-out. Pull these out of the top
  Toolbar trailing slot.
- Align depth-0 folder rows with the rest of the sidebar entries (drop
  the leading chevron column when no children) and bring heap rows in
  line with folder rows so the kebab is part of the row's hover
  background instead of a detached chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 16:51:56 +02:00
parent 17df1ecd09
commit 0766b47bb2
7 changed files with 729 additions and 43 deletions

View File

@@ -110,15 +110,15 @@
<!--
Indent via padding-left rather than nested margin+border, so the
active row's background bleeds edge-to-edge of the sidebar (matches
mule-image's compact tree). Depth × 12px keeps lines aligned with
the chevron of the previous level.
mule-image's compact tree). 8px baseline aligns the depth-0 chevron
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
<div
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: {depth * 12}px;"
style="padding-left: {8 + depth * 12}px;"
>
{#if hasChildren}
<button
@@ -130,11 +130,15 @@
>
{open ? '▾' : '▸'}
</button>
{:else}
{:else if depth > 0}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at nested depths. Skipped at depth 0 so root folders
left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate px-1 text-left"
class="flex flex-1 items-center truncate text-left"
class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}

View File

@@ -0,0 +1,83 @@
<!--
General app preferences. Distinct from the PhotoPrism-library admin dialog:
this one owns settings that affect *this* SvelteKit shell (theme), not the
server. Opened from the bottom of the left sidebar.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { mode, setMode } from 'mode-watcher';
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app. Library-side settings live under
Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Appearance
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{/each}
</div>
</section>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { mode, toggleMode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import {
createFolder,
@@ -12,6 +13,7 @@
heapDownloadUrl,
listFolders,
listHeaps,
logout,
renameFolder,
renameHeap,
triggerDownload,
@@ -24,11 +26,23 @@
setSection,
type Section
} from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { isAuthenticated, session } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import { Copy, Download, FolderInput, Pencil, Trash2 } from 'lucide-svelte';
import SettingsDialog from './SettingsDialog.svelte';
import {
Copy,
Download,
FolderInput,
LogOut,
Moon,
Pencil,
Settings,
Sun,
Trash2
} from 'lucide-svelte';
const qc = useQueryClient();
@@ -88,6 +102,18 @@
// mounts <HeapConvertDialog>; the dialog clears it on close.
let convertingHeap = $state<PpAlbum | null>(null);
// Library/admin settings dialog visibility.
let settingsOpen = $state(false);
// App-wide preferences dialog (theme etc.). Distinct from the library
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
}
async function navigateTo(section: Section, heapUid: string | null = null) {
setSection(section, heapUid);
setFolderPath(null);
@@ -220,7 +246,8 @@
}
</script>
<nav class="space-y-3">
<div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
<!-- Views — section-driven entries + route-driven entries under a
single uppercase eyebrow. Compact rows, no icons. -->
<div>
@@ -279,12 +306,14 @@
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<li class="group flex items-center">
<li
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<button
class="flex h-[24px] flex-1 items-center gap-2 rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
class="flex flex-1 items-center gap-2 px-2 text-left"
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
@@ -298,7 +327,7 @@
{heap.PhotoCount ?? 0}
</span>
</button>
<div class="pl-0.5">
<div class="mr-1">
<KebabMenu label="Heap actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
@@ -345,10 +374,18 @@
</div>
<div>
<div class="group/header flex items-center px-3 pb-1">
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Folders
</span>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
@@ -386,4 +423,57 @@
</div>
</nav>
<!--
Footer — fixed to the bottom of the sidebar. Holds the per-user
affordances (display name, quick theme toggle, general preferences,
sign-out) that used to live in the top toolbar.
-->
<footer
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
>
<span
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
title={session.user?.DisplayName ?? session.user?.Name ?? ''}
>
{session.user?.DisplayName ?? session.user?.Name ?? 'Signed in'}
</span>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={toggleMode}
title="Toggle theme"
aria-label="Toggle theme"
>
{#if mode.current === 'dark'}
<Sun class="h-3.5 w-3.5" />
{:else}
<Moon class="h-3.5 w-3.5" />
{/if}
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (generalSettingsOpen = true)}
title="General settings"
aria-label="General settings"
>
<Settings class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={onSignOut}
title="Sign out"
aria-label="Sign out"
>
<LogOut class="h-3.5 w-3.5" />
</button>
</footer>
</div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog
open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)}
/>

View File

@@ -0,0 +1,439 @@
<!--
Library admin dialog. Tabs map 1-to-1 to PhotoPrism's own Library page:
general settings, manual index, manual import, server error log.
Each tab owns its own query/mutation pair via TanStack Query so the data
loads on first open and the rest of the app can read the same caches.
-->
<script lang="ts">
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import {
cancelImport,
cancelIndex,
getErrors,
getSettings,
saveSettings,
startImport,
startIndex,
type ImportBody,
type IndexBody,
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library');
// ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them
// warm in the background. Edits work on a local clone; Save POSTs it
// back wholesale (PhotoPrism deep-merges server-side).
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
}
});
// Reset the draft when the dialog closes so the next open re-reads.
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = 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 = structuredClone(settingsQuery.data);
}
// ── Index tab ─────────────────────────────────────────────────────────
let indexForm = $state<IndexBody>({ path: '/', rescan: false, cleanup: false });
const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Index failed')
}));
const cancelIndexMut = createMutation(() => ({
mutationFn: () => cancelIndex(),
onSuccess: () => toast.success('Indexing canceled'),
onError: (err) =>
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.
const errorsQuery = createQuery<PpLogEntry[]>(() => ({
queryKey: ['errors'],
queryFn: () => getErrors({ limit: 200 }),
enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false
}));
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<Settings class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
Library settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Drive PhotoPrism's library, indexer, importer and server log.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<Tabs.Root bind:value={activeTab}>
<Tabs.List
class="mb-3 flex gap-1 border-b border-border"
>
{#each ['library', 'index', 'import', 'logs'] 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"
>
{t}
</Tabs.Trigger>
{/each}
</Tabs.List>
<!-- Library — general settings -->
<Tabs.Content value="library" class="outline-none">
{#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
{:else if draft}
<div class="max-h-[55vh] space-y-4 overflow-y-auto pr-1 text-[12px]">
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Indexer defaults
</h3>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.convert}
/>
Convert RAW / HEIC on index
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.rescan}
/>
Rescan known files
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipArchived}
/>
Skip archived photos
</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
</h3>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.uuid} />
Stack files sharing a UUID
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.meta} />
Stack files with matching metadata
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.name} />
Stack files with matching names
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Downloads
</h3>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.originals}
/>
Originals
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.mediaRaw}
/>
Include RAW
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.mediaSidecar}
/>
Include sidecar files
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.disabled}
/>
Disable downloads entirely
</label>
</section>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<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}
</Tabs.Content>
<!-- Index — manual indexer run -->
<Tabs.Content value="index" class="space-y-3 text-[12px] outline-none">
<p class="text-muted-foreground">
Rebuilds the search index by walking the originals folder. Safe to
run while users are connected.
</p>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Path</span>
<input
type="text"
bind:value={indexForm.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={indexForm.rescan} />
Rescan files already in the index
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={indexForm.cleanup} />
Clean up missing files
</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={() => cancelIndexMut.mutate()}
disabled={cancelIndexMut.isPending || startIndexMut.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={() => startIndexMut.mutate(indexForm)}
disabled={startIndexMut.isPending}
>
{#if startIndexMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Start indexing
</button>
</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>
<!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between">
<p class="text-muted-foreground">
Most recent PhotoPrism errors and warnings. Auto-refreshes
every 5 seconds.
</p>
<button
type="button"
class="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
onclick={() => qc.invalidateQueries({ queryKey: ['errors'] })}
disabled={errorsQuery.isFetching}
>
<RefreshCw
class="h-3 w-3 {errorsQuery.isFetching ? 'animate-spin' : ''}"
/>
Refresh
</button>
</div>
{#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p>
{:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p>
{:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p>
{:else}
<ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
>
{#each errorsQuery.data ?? [] as entry, i (i)}
<li class="leading-snug">
<span class="text-muted-foreground">{entry.Time}</span>
<span
class:text-destructive={entry.Level === 'error'}
class:text-yellow-500={entry.Level === 'warn' ||
entry.Level === 'warning'}
>
[{entry.Level}]
</span>
{entry.Message}
</li>
{/each}
</ul>
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -602,6 +602,92 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
return data as RenameResult;
}
// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / 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.
export interface PpSettings {
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
name?: string;
disabled?: boolean;
originals?: boolean;
mediaRaw?: boolean;
mediaSidecar?: boolean;
};
[k: string]: unknown;
}
export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
}
export async function saveSettings(patch: Partial<PpSettings>): Promise<PpSettings> {
const { data } = await http.post<PpSettings>('/settings', patch);
return data;
}
export interface IndexBody {
path?: string;
rescan?: boolean;
cleanup?: boolean;
}
export async function startIndex(body: IndexBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/index', {
path: '/',
rescan: false,
cleanup: false,
...body
});
return data;
}
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;
Message: string;
}
export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEntry[]> {
const { data } = await http.get<PpLogEntry[]>('/errors', {
params: { limit: opts.limit ?? 200 }
});
return data ?? [];
}
// ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

View File

@@ -48,9 +48,10 @@
class="relative hidden h-full shrink-0 border-r border-border bg-card/30 md:block"
style="width: {view.leftSidebarWidth}px;"
>
<div class="h-full overflow-y-auto p-3">
<LeftSidebar />
</div>
<!-- LeftSidebar owns its own flex-col layout so its footer
row (user/theme/settings/logout) can pin to the bottom
while the nav above scrolls. -->
<LeftSidebar />
<div
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
use:resizable={{

View File

@@ -3,14 +3,12 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toggleMode, mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import {
batchDelete,
getPhoto,
listHeaps,
listPhotos,
logout,
type PpAlbum
} from '$lib/services/photoprism';
import {
@@ -21,7 +19,7 @@
setSearch,
setSection
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, thumbUrl } from '$lib/stores/session.svelte';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { untrack } from 'svelte';
import {
isSelected,
@@ -52,7 +50,7 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
// ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store.
@@ -514,11 +512,6 @@
staleTime: 0
}));
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
}
const qc = useQueryClient();
let emptyingArchive = $state(false);
@@ -660,22 +653,6 @@
{/each}
</div>
<span class="hidden text-[11px] text-muted-foreground sm:inline">
{session.user?.DisplayName ?? session.user?.Name}
</span>
<button
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={toggleMode}
title="Toggle theme"
>
{mode.current === 'dark' ? '☀' : '☾'}
</button>
<button
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={onSignOut}
>
Sign out
</button>
{/snippet}
</Toolbar>
@@ -796,6 +773,12 @@
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/if}
</div>