Mulimage 2.0 #1
@@ -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}
|
||||
|
||||
83
web/src/lib/components/layout/GeneralSettingsDialog.svelte
Normal file
83
web/src/lib/components/layout/GeneralSettingsDialog.svelte
Normal 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>
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
439
web/src/lib/components/layout/SettingsDialog.svelte
Normal file
439
web/src/lib/components/layout/SettingsDialog.svelte
Normal 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>
|
||||
@@ -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 };
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user