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

@@ -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>