Mulimage 2.0 #1
@@ -110,15 +110,15 @@
|
|||||||
<!--
|
<!--
|
||||||
Indent via padding-left rather than nested margin+border, so the
|
Indent via padding-left rather than nested margin+border, so the
|
||||||
active row's background bleeds edge-to-edge of the sidebar (matches
|
active row's background bleeds edge-to-edge of the sidebar (matches
|
||||||
mule-image's compact tree). Depth × 12px keeps lines aligned with
|
mule-image's compact tree). 8px baseline aligns the depth-0 chevron
|
||||||
the chevron of the previous level.
|
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||||
class:bg-primary={active}
|
class:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
style="padding-left: {depth * 12}px;"
|
style="padding-left: {8 + depth * 12}px;"
|
||||||
>
|
>
|
||||||
{#if hasChildren}
|
{#if hasChildren}
|
||||||
<button
|
<button
|
||||||
@@ -130,11 +130,15 @@
|
|||||||
>
|
>
|
||||||
{open ? '▾' : '▸'}
|
{open ? '▾' : '▸'}
|
||||||
</button>
|
</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>
|
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<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)}
|
onclick={() => onPick(node.path)}
|
||||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
title={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 { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { mode, toggleMode } from 'mode-watcher';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
createFolder,
|
createFolder,
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
heapDownloadUrl,
|
heapDownloadUrl,
|
||||||
listFolders,
|
listFolders,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
|
logout,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
@@ -24,11 +26,23 @@
|
|||||||
setSection,
|
setSection,
|
||||||
type Section
|
type Section
|
||||||
} from '$lib/stores/filters.svelte';
|
} 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 FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
|
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
||||||
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.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();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -88,6 +102,18 @@
|
|||||||
// mounts <HeapConvertDialog>; the dialog clears it on close.
|
// mounts <HeapConvertDialog>; the dialog clears it on close.
|
||||||
let convertingHeap = $state<PpAlbum | null>(null);
|
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) {
|
async function navigateTo(section: Section, heapUid: string | null = null) {
|
||||||
setSection(section, heapUid);
|
setSection(section, heapUid);
|
||||||
setFolderPath(null);
|
setFolderPath(null);
|
||||||
@@ -220,7 +246,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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
|
<!-- Views — section-driven entries + route-driven entries under a
|
||||||
single uppercase eyebrow. Compact rows, no icons. -->
|
single uppercase eyebrow. Compact rows, no icons. -->
|
||||||
<div>
|
<div>
|
||||||
@@ -279,12 +306,14 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
{@const active = isActive('heap', heap.UID)}
|
{@const active = isActive('heap', heap.UID)}
|
||||||
<li class="group flex items-center">
|
<li
|
||||||
<button
|
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||||
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:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex flex-1 items-center gap-2 px-2 text-left"
|
||||||
onclick={() => navigateTo('heap', heap.UID)}
|
onclick={() => navigateTo('heap', heap.UID)}
|
||||||
ondblclick={() => onRenameHeap(heap)}
|
ondblclick={() => onRenameHeap(heap)}
|
||||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||||
@@ -298,7 +327,7 @@
|
|||||||
{heap.PhotoCount ?? 0}
|
{heap.PhotoCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="pl-0.5">
|
<div class="mr-1">
|
||||||
<KebabMenu label="Heap actions">
|
<KebabMenu label="Heap actions">
|
||||||
<Item
|
<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"
|
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>
|
<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">
|
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Folders
|
Folders
|
||||||
</span>
|
</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
|
<button
|
||||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
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)}
|
onclick={() => onCreateFolder(null)}
|
||||||
@@ -386,4 +423,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</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)} />
|
<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;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Re-exports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|
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"
|
class="relative hidden h-full shrink-0 border-r border-border bg-card/30 md:block"
|
||||||
style="width: {view.leftSidebarWidth}px;"
|
style="width: {view.leftSidebarWidth}px;"
|
||||||
>
|
>
|
||||||
<div class="h-full overflow-y-auto p-3">
|
<!-- 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 />
|
<LeftSidebar />
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
|
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
|
||||||
use:resizable={{
|
use:resizable={{
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toggleMode, mode } from 'mode-watcher';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
batchDelete,
|
batchDelete,
|
||||||
getPhoto,
|
getPhoto,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
logout,
|
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,7 @@
|
|||||||
setSearch,
|
setSearch,
|
||||||
setSection
|
setSection
|
||||||
} from '$lib/stores/filters.svelte';
|
} 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 { untrack } from 'svelte';
|
||||||
import {
|
import {
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -52,7 +50,7 @@
|
|||||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.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 ──────────────────────────────────────────────
|
// ── URL ↔ filter store sync ──────────────────────────────────────────────
|
||||||
// On nav (back/forward, deep link), reflect the URL into the store.
|
// On nav (back/forward, deep link), reflect the URL into the store.
|
||||||
@@ -514,11 +512,6 @@
|
|||||||
staleTime: 0
|
staleTime: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function onSignOut() {
|
|
||||||
await logout();
|
|
||||||
await goto('/login', { replaceState: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
let emptyingArchive = $state(false);
|
let emptyingArchive = $state(false);
|
||||||
|
|
||||||
@@ -660,22 +653,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
@@ -796,6 +773,12 @@
|
|||||||
>♥</span
|
>♥</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user