Lets a user pick a sub-folder under their library as a working index root, stored server-side (new sidecar user_prefs table). The Library tree, reindex, and both duplicate views (stacks + cross-folder scan) now re-root to it via a single userLibraryBase() helper. Also fixes the cross-folder scan/archive endpoints, which previously walked/touched the whole originals root instead of being scoped per-user (archive now rejects out-of-scope paths, 403). Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only steered PhotoPrism's own bundled SPA and were never read by mulimage's UI. Also fixes the Library tree occasionally getting stuck on "Loading folders…" by dropping gcTime:0 and gating the spinner on isLoading instead of isPending. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
752 lines
28 KiB
Svelte
752 lines
28 KiB
Svelte
<!--
|
|
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 { AlertCircle, CheckCircle2, FolderOpen, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
|
import {
|
|
cancelIndex,
|
|
getConfig,
|
|
getErrors,
|
|
getSettings,
|
|
getIndexSubpath,
|
|
listFoldersUnderBase,
|
|
saveSettings,
|
|
setIndexSubpath,
|
|
startIndex,
|
|
type IndexBody,
|
|
type PpFolder,
|
|
type PpLogEntry,
|
|
type PpSettings
|
|
} from '$lib/services/photoprism';
|
|
import type { PpClientConfig } from '$lib/types/photoprism';
|
|
import {
|
|
prefs,
|
|
setIndexSubpathState,
|
|
toOriginalsPath
|
|
} from '$lib/stores/session.svelte';
|
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
let { open, onClose }: Props = $props();
|
|
|
|
const qc = useQueryClient();
|
|
|
|
let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('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
|
|
}));
|
|
|
|
/**
|
|
* Force the shape on every clone so each `bind:value={draft.index!.*}`
|
|
* etc. has a real object to write into. Older PhotoPrism versions
|
|
* return /settings without one or more of these sub-objects, and
|
|
* non-null assertions on a missing sub-object throw on the next tick
|
|
* when Svelte's bind getter reads through it.
|
|
*
|
|
* Same shape-coercion pattern used by GeneralSettingsDialog —
|
|
* keep them in sync if you add a new top-level group there.
|
|
*/
|
|
function normalize(s: PpSettings): PpSettings {
|
|
return {
|
|
...s,
|
|
index: s.index ?? {},
|
|
stack: s.stack ?? {},
|
|
download: s.download ?? {}
|
|
};
|
|
}
|
|
|
|
let draft = $state<PpSettings | null>(null);
|
|
// Re-clone on every open so reopening shows the freshest server state.
|
|
// Resetting on open (not close) avoids the race where bits-ui's exit
|
|
// animation keeps the form mounted with `draft === null` and the
|
|
// `bind:value={draft.download!.originals}` getter throws.
|
|
$effect(() => {
|
|
if (open && settingsQuery.data) {
|
|
draft = normalize(structuredClone(settingsQuery.data));
|
|
}
|
|
});
|
|
|
|
const saveMut = createMutation(() => ({
|
|
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
|
onSuccess: (next) => {
|
|
qc.setQueryData(['settings'], next);
|
|
draft = normalize(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 = normalize(structuredClone(settingsQuery.data));
|
|
}
|
|
|
|
// ── Index folder (per-user, server-side) ──────────────────────────────
|
|
// The originals-relative sub-folder, under the user's BasePath, that the
|
|
// whole app re-roots to (Library tree) and the reindex scopes to. Picked
|
|
// from the *full* BasePath tree (listFoldersUnderBase) so the user can
|
|
// choose any sub-folder — including ones outside the current root. Stored
|
|
// by the sidecar; mirrored into the `prefs` store so the sidebar reacts.
|
|
const subpathFoldersQuery = createQuery<PpFolder[]>(() => ({
|
|
queryKey: ['folders-under-base'],
|
|
queryFn: listFoldersUnderBase,
|
|
enabled: open && activeTab === 'library'
|
|
}));
|
|
const subpathTree = $derived(
|
|
buildTree((subpathFoldersQuery.data ?? []).map((f) => f.Path))
|
|
);
|
|
|
|
// Hydrate the picker selection from the server pref when the dialog opens,
|
|
// so it reflects the current choice instead of the in-memory store alone.
|
|
const indexPrefQuery = createQuery<string>(() => ({
|
|
queryKey: ['prefs'],
|
|
queryFn: getIndexSubpath,
|
|
enabled: open
|
|
}));
|
|
// Local selection: '' = whole folder. Seeded from the store, then from the
|
|
// server pref once it loads.
|
|
let pickedSubpath = $state<string>(prefs.indexSubpath);
|
|
$effect(() => {
|
|
if (open && indexPrefQuery.data !== undefined) {
|
|
pickedSubpath = indexPrefQuery.data;
|
|
}
|
|
});
|
|
|
|
const saveSubpathMut = createMutation(() => ({
|
|
mutationFn: (sub: string) => setIndexSubpath(sub),
|
|
onSuccess: (saved) => {
|
|
setIndexSubpathState(saved);
|
|
qc.setQueryData(['prefs'], saved);
|
|
// Re-root the sidebar tree + grid: both are keyed on the effective
|
|
// library base, which just changed.
|
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
|
toast.success(saved === '' ? 'Indexing whole folder' : `Index folder: ${saved}`);
|
|
},
|
|
onError: (err) =>
|
|
toast.error(err instanceof Error ? err.message : 'Could not save index folder')
|
|
}));
|
|
|
|
// ── Index tab ─────────────────────────────────────────────────────────
|
|
// Default the reindex path to the effective library root (BasePath +
|
|
// chosen index sub-path), so a manual run only rescans the user's working
|
|
// subtree. PhotoPrism's /index expects originals-relative paths with a
|
|
// leading slash; `'/'` means the whole library.
|
|
let indexForm = $state<IndexBody>({
|
|
path: '/' + toOriginalsPath('/'),
|
|
rescan: false,
|
|
cleanup: false
|
|
});
|
|
// SettingsDialog is mounted (open=false) before the index sub-path
|
|
// hydrates, so re-seed the manual-run path to the effective library root
|
|
// each time the dialog opens (and whenever the chosen root changes).
|
|
$effect(() => {
|
|
if (open) indexForm.path = '/' + toOriginalsPath('/');
|
|
});
|
|
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')
|
|
}));
|
|
|
|
// ── 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
|
|
}));
|
|
|
|
// ── About tab ─────────────────────────────────────────────────────────
|
|
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
|
|
// so the About tab never triggers an extra round-trip — config is
|
|
// already warm by the time the user opens this dialog.
|
|
const configQuery = createQuery<PpClientConfig>(() => ({
|
|
queryKey: ['photos', 'config'],
|
|
queryFn: getConfig,
|
|
enabled: open && activeTab === 'about'
|
|
}));
|
|
|
|
// PhotoPrism's `flags` is a space-separated bag of feature toggles
|
|
// ("experimental tensorflow places webdav share download import oidc").
|
|
// Parse once so the chip grid can render in stable order.
|
|
const flagSet = $derived.by<Set<string>>(() => {
|
|
const raw = configQuery.data?.flags ?? '';
|
|
return new Set(raw.split(/\s+/).filter(Boolean));
|
|
});
|
|
|
|
// Env-driven knobs that don't have a runtime PP API. Listed here so the
|
|
// About tab can render a "you need to edit .env and restart" help
|
|
// section instead of pretending these are mutable from the UI.
|
|
interface EnvKnob {
|
|
envVar: string;
|
|
label: string;
|
|
on: boolean;
|
|
}
|
|
const envKnobs = $derived.by<EnvKnob[]>(() => {
|
|
const f = flagSet;
|
|
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
|
|
return [
|
|
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
|
|
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
|
|
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
|
|
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
|
|
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
|
|
];
|
|
});
|
|
|
|
// Show the config block collapsed by default — most users only want the
|
|
// version + counts; the env help is for the rare admin moment.
|
|
let envHelpOpen = $state(false);
|
|
|
|
// Library counts surfaced as a compact 2-column grid. Order matches
|
|
// what users care about most often (photos, then derived buckets).
|
|
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
|
|
{ key: 'all', label: 'Photos' },
|
|
{ key: 'videos', label: 'Videos' },
|
|
{ key: 'live', label: 'Live photos' },
|
|
{ key: 'favorites', label: 'Favorites' },
|
|
{ key: 'review', label: 'In review' },
|
|
{ key: 'archived', label: 'Archived' },
|
|
{ key: 'hidden', label: 'Hidden' },
|
|
{ key: 'people', label: 'People' },
|
|
{ key: 'labels', label: 'Labels' },
|
|
{ key: 'folders', label: 'Folders' },
|
|
{ key: 'albums', label: 'Albums' }
|
|
];
|
|
</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 the 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', 'logs', 'about'] 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="space-y-4 outline-none">
|
|
<!-- Index folder — the per-user sub-folder the Library tree
|
|
re-roots to and the reindex scopes to. Picked from the
|
|
full BasePath tree so any sub-folder is reachable. -->
|
|
<section class="space-y-2 text-[12px]">
|
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Index folder
|
|
</h3>
|
|
<p class="text-muted-foreground">
|
|
Pick the sub-folder PhotoPrism should treat as your library
|
|
root. The folder tree re-roots here and the reindex only scans
|
|
this subtree. Leave on “Whole folder” to use everything.
|
|
</p>
|
|
<div class="rounded-md border border-border bg-background p-2">
|
|
<div class="max-h-[180px] overflow-y-auto">
|
|
{#if subpathFoldersQuery.isPending}
|
|
<InlineLoader size="sm" label="Loading folders…" />
|
|
{:else if subpathFoldersQuery.isError}
|
|
<EmptyState
|
|
size="compact"
|
|
tone="destructive"
|
|
icon={FolderOpen}
|
|
title="Could not load folders"
|
|
/>
|
|
{:else}
|
|
<!-- Whole-folder reset: '' is the "no sub-path" sentinel. -->
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
|
class:bg-primary={pickedSubpath === ''}
|
|
class:text-primary-foreground={pickedSubpath === ''}
|
|
class:hover:bg-primary={pickedSubpath === ''}
|
|
onclick={() => (pickedSubpath = '')}
|
|
>
|
|
Whole folder
|
|
</button>
|
|
{#if (subpathFoldersQuery.data ?? []).length > 0}
|
|
<FolderTree
|
|
nodes={subpathTree}
|
|
onPick={(p) => (pickedSubpath = p)}
|
|
selectedPath={pickedSubpath}
|
|
readonly
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="truncate text-[11px] text-muted-foreground">
|
|
Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath}
|
|
</span>
|
|
<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={() => saveSubpathMut.mutate(pickedSubpath)}
|
|
disabled={saveSubpathMut.isPending || pickedSubpath === prefs.indexSubpath}
|
|
>
|
|
{#if saveSubpathMut.isPending}
|
|
<Loader2 class="h-3 w-3 animate-spin" />
|
|
{/if}
|
|
Set index folder
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="h-px bg-border"></div>
|
|
|
|
{#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">
|
|
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>
|
|
{#if draft.download?.crc32 !== undefined}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft.download!.crc32}
|
|
/>
|
|
Include CRC32 checksum
|
|
</label>
|
|
{/if}
|
|
{#if draft.download?.sha1 !== undefined}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft.download!.sha1}
|
|
/>
|
|
Include SHA1 checksum
|
|
</label>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Indexer advanced — only renders the fields PP actually
|
|
reported. Older PP versions return a smaller `index`
|
|
block and we don't want to fabricate UI for missing keys. -->
|
|
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
|
|
<section class="space-y-1.5">
|
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Indexer advanced
|
|
</h3>
|
|
{#if draft.index?.skipMeta !== undefined}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft.index!.skipMeta}
|
|
/>
|
|
Skip metadata-only changes
|
|
</label>
|
|
{/if}
|
|
{#if draft.index?.skipRaw !== undefined}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft.index!.skipRaw}
|
|
/>
|
|
Skip RAW files
|
|
</label>
|
|
{/if}
|
|
{#if draft.index?.skipHidden !== undefined}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={draft.index!.skipHidden}
|
|
/>
|
|
Skip hidden files
|
|
</label>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
</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>
|
|
|
|
<!-- About — version, library counts, env-driven config help -->
|
|
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
|
|
{#if configQuery.isPending}
|
|
<InlineLoader size="sm" label="Loading server info…" />
|
|
{:else if configQuery.isError || !configQuery.data}
|
|
<EmptyState
|
|
size="compact"
|
|
tone="destructive"
|
|
icon={AlertCircle}
|
|
title="Could not load server info"
|
|
/>
|
|
{:else}
|
|
{@const cfg = configQuery.data}
|
|
<section class="space-y-1.5">
|
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Server
|
|
</h3>
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
|
<span class="text-muted-foreground">PhotoPrism</span>
|
|
<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
|
|
<span class="text-muted-foreground">Site</span>
|
|
<span class="truncate text-right" title={cfg.siteUrl}>
|
|
{cfg.siteUrl || '—'}
|
|
</span>
|
|
<span class="text-muted-foreground">Auth mode</span>
|
|
<span class="text-right">{cfg.mode}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="space-y-1.5">
|
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Features
|
|
</h3>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each envKnobs as knob (knob.envVar)}
|
|
<span
|
|
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
|
|
? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
|
|
: 'border-border bg-secondary text-muted-foreground'}"
|
|
title={knob.envVar}
|
|
>
|
|
<span
|
|
class="h-1.5 w-1.5 rounded-full {knob.on
|
|
? 'bg-green-500'
|
|
: 'bg-muted-foreground/40'}"
|
|
></span>
|
|
{knob.label}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
{#if cfg.count}
|
|
<section class="space-y-1.5">
|
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Library
|
|
</h3>
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
|
{#each COUNT_ROWS as row (row.key)}
|
|
{@const v = cfg.count?.[row.key]}
|
|
{#if v !== undefined}
|
|
<span class="text-muted-foreground">{row.label}</span>
|
|
<span class="text-right tabular-nums">{v}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- Env-driven config: there is no PhotoPrism API for these.
|
|
The panel surfaces what's on/off and reminds the admin
|
|
where to flip the switch — .env + restart. -->
|
|
<section class="space-y-2">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
|
|
onclick={() => (envHelpOpen = !envHelpOpen)}
|
|
aria-expanded={envHelpOpen}
|
|
>
|
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
Server configuration (env-driven)
|
|
</span>
|
|
<span class="text-[10px] text-muted-foreground">
|
|
{envHelpOpen ? '▾' : '▸'}
|
|
</span>
|
|
</button>
|
|
{#if envHelpOpen}
|
|
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
|
|
<p class="text-muted-foreground">
|
|
These knobs aren't exposed through the API. Edit
|
|
<code class="rounded bg-background px-1">.env</code>
|
|
on the host and restart PhotoPrism:
|
|
</p>
|
|
<pre
|
|
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
|
|
>docker compose up -d photoprism
|
|
# or, with podman-compose:
|
|
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
|
|
<ul class="space-y-0.5">
|
|
{#each envKnobs as knob (knob.envVar)}
|
|
<li>
|
|
<code class="rounded bg-background px-1">{knob.envVar}</code>
|
|
—
|
|
<span class:text-green-600={knob.on}
|
|
class:text-muted-foreground={!knob.on}>
|
|
{knob.on ? 'enabled' : 'disabled'}
|
|
</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if cfg.ext?.oidc?.enabled}
|
|
<p class="text-muted-foreground">
|
|
OIDC provider:
|
|
<span class="text-foreground">
|
|
{cfg.ext.oidc.provider ?? '—'}
|
|
</span>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
</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 server 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}
|
|
<InlineLoader size="sm" label="Loading error log…" />
|
|
{:else if errorsQuery.isError}
|
|
<EmptyState
|
|
size="compact"
|
|
tone="destructive"
|
|
icon={AlertCircle}
|
|
title="Could not load error log"
|
|
/>
|
|
{:else if (errorsQuery.data ?? []).length === 0}
|
|
<EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
|
|
{: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>
|