web: admin surfaces so the PhotoPrism UI is never needed
- Account tab in General settings — self-service password change. - UsersDialog (admin-only footer entry) — full /api/v1/users CRUD with admin-issued password reset. - People as a fifth tag category alongside Labels/Keywords/Colors/Ratings, backed by /api/v1/subjects and the `person:` DSL clause. - About tab in Library settings — version, library counts, feature chips, and a collapsible env-config help panel for the bits PP has no runtime API for (OIDC, TF, WebDAV). - Library tab expanded with Indexer-advanced, extra Downloads checksums, and a Features grid that only renders keys PhotoPrism actually returns. - Fix the SettingsDialog null-draft race the same way GeneralSettingsDialog already had: normalize on open, never null on close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import {
|
||||
cancelImport,
|
||||
cancelIndex,
|
||||
getConfig,
|
||||
getErrors,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
@@ -24,6 +25,7 @@
|
||||
type PpLogEntry,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import type { PpClientConfig } from '$lib/types/photoprism';
|
||||
import { userBasePath } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -34,7 +36,7 @@
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library');
|
||||
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
|
||||
|
||||
// ── Library tab ───────────────────────────────────────────────────────
|
||||
// Pull settings only while the dialog is open so we don't keep them
|
||||
@@ -46,22 +48,42 @@
|
||||
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 ?? {},
|
||||
import: s.import ?? {},
|
||||
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 (settingsQuery.data && draft === null) {
|
||||
draft = structuredClone(settingsQuery.data);
|
||||
if (open && settingsQuery.data) {
|
||||
draft = normalize(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);
|
||||
draft = normalize(structuredClone(next));
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (err) =>
|
||||
@@ -69,7 +91,7 @@
|
||||
}));
|
||||
|
||||
function resetDraft() {
|
||||
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
|
||||
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
|
||||
// ── Index tab ─────────────────────────────────────────────────────────
|
||||
@@ -120,6 +142,64 @@
|
||||
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
|
||||
@@ -157,7 +237,7 @@
|
||||
<Tabs.List
|
||||
class="mb-3 flex gap-1 border-b border-border"
|
||||
>
|
||||
{#each ['library', 'index', 'import', 'logs'] as const as t (t)}
|
||||
{#each ['library', 'index', 'import', '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"
|
||||
@@ -273,8 +353,92 @@
|
||||
/>
|
||||
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}
|
||||
|
||||
<!-- Features — PhotoPrism's gating bag. Render only the
|
||||
keys actually present in the response (PP version
|
||||
drift), labelled human-readably. -->
|
||||
{#if draft.features && Object.keys(draft.features).length > 0}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Features
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Toggling a feature off hides it from PhotoPrism's own
|
||||
UI and disables the underlying API surface.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each Object.keys(draft.features).sort() as key (key)}
|
||||
{#if typeof draft.features![key] === 'boolean'}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.features![key]}
|
||||
/>
|
||||
<span class="capitalize">{key}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
@@ -399,6 +563,130 @@
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user