Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
7 changed files with 729 additions and 43 deletions
Showing only changes of commit 0766b47bb2 - Show all commits

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>