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:
2026-05-20 08:25:53 +02:00
parent 24dfa996b3
commit a7b8a60473
8 changed files with 1196 additions and 37 deletions

View File

@@ -16,8 +16,10 @@
import { import {
getSettings, getSettings,
saveSettings, saveSettings,
setUserPassword,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -27,7 +29,29 @@
const qc = useQueryClient(); const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps'>('ui'); let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
// ── Account tab — password change ─────────────────────────────────────
let pwOld = $state('');
let pwNew = $state('');
let pwConfirm = $state('');
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!session.user) throw new Error('Not signed in');
if (pwNew.length < 8) throw new Error('New password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
await setUserPassword(session.user.UID, pwOld, pwNew);
},
onSuccess: () => {
pwOld = '';
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
const themeOptions = [ const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun }, { value: 'light', label: 'Light', Icon: Sun },
@@ -160,8 +184,8 @@
General settings General settings
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground"> <Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app and your PhotoPrism account. Preferences for Mulimage and your account. Library admin lives
Library admin lives under Folders → ⚙. under Folders → ⚙.
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close <Dialog.Close
@@ -174,7 +198,7 @@
<Tabs.Root bind:value={activeTab}> <Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border"> <Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps'] as const as t (t)} {#each ['ui', 'search', 'maps', 'account'] as const as t (t)}
<Tabs.Trigger <Tabs.Trigger
value={t} 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" 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"
@@ -217,13 +241,13 @@
</section> </section>
{#if settingsQuery.isPending} {#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p> <p class="px-1 text-muted-foreground">Loading server settings…</p>
{:else if settingsQuery.isError} {:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p> <p class="px-1 text-destructive">Could not load server settings.</p>
{:else if draft} {:else if draft}
<section class="space-y-3"> <section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism UI Server UI
</h3> </h3>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span> <span class="text-muted-foreground">Theme</span>
@@ -275,11 +299,11 @@
{/if} {/if}
</Tabs.Content> </Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'} {#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none"> <Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p> <p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content> </Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui'} {:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none"> <Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive"> <p class="px-1 text-[12px] text-destructive">
Could not load settings. Could not load settings.
@@ -332,6 +356,97 @@
</label> </label>
</Tabs.Content> </Tabs.Content>
{/if} {/if}
<!-- Account — independent of /settings; reads from the session
store and round-trips its own mutation. -->
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Signed in as
</h3>
<div class="space-y-1 rounded border border-border bg-muted/30 p-2">
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Name</span>
<span class="font-medium">{session.user?.Name ?? '—'}</span>
</div>
{#if session.user?.DisplayName}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Display name</span>
<span>{session.user.DisplayName}</span>
</div>
{/if}
{#if session.user?.Email}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Email</span>
<span>{session.user.Email}</span>
</div>
{/if}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Role</span>
<span>{session.user?.Role ?? '—'}</span>
</div>
</div>
</section>
<form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Change password
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Current password</span>
<input
type="password"
autocomplete="current-password"
bind:value={pwOld}
required
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">New password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwNew}
required
minlength={8}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Confirm new password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwConfirm}
required
minlength={8}
class={selectClass}
/>
</label>
<div class="flex justify-end">
<button
type="submit"
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"
disabled={pwMut.isPending ||
!pwOld ||
pwNew.length < 8 ||
pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Update password
</button>
</div>
</form>
</Tabs.Content>
</Tabs.Root> </Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the <!-- Datalist for time-zone autocomplete. Falls back to the
@@ -344,8 +459,9 @@
<!-- Save/Revert apply to draft (the PhotoPrism /settings round <!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we trip). The App theme group above persists itself, so we
only show the action row when there's something to save. --> only show the action row when there's something to save.
{#if draft} Account tab has its own Update-password button, so skip. -->
{#if draft && activeTab !== 'account'}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3"> <div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button <button
type="button" type="button"

View File

@@ -51,6 +51,7 @@
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 SettingsDialog from './SettingsDialog.svelte'; import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import { import {
Copy, Copy,
Download, Download,
@@ -63,7 +64,8 @@
Pencil, Pencil,
Settings, Settings,
Sun, Sun,
Trash2 Trash2,
Users
} from 'lucide-svelte'; } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -351,6 +353,10 @@
// admin dialog above — opened from the bottom-of-sidebar footer. // admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false); let generalSettingsOpen = $state(false);
// Admin-only user management dialog. Footer icon is gated on
// `isAdminUser` so non-admins never see the entry point.
let usersOpen = $state(false);
// Root-folder collapse state. Persisted to its own localStorage key so // Root-folder collapse state. Persisted to its own localStorage key so
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults // it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree. // to open so first-time users see the full tree.
@@ -385,6 +391,7 @@
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = { const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels', labels: 'Labels',
keywords: 'Keywords', keywords: 'Keywords',
people: 'People',
colors: 'Colors', colors: 'Colors',
ratings: 'Ratings' ratings: 'Ratings'
}; };
@@ -399,6 +406,10 @@
// Keywords sub-row's distinct-count semantics. // Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels; if (cat === 'labels') return configQuery.data?.count?.labels;
if (cat === 'keywords') return keywordsQuery.data?.length; if (cat === 'keywords') return keywordsQuery.data?.length;
// People follows the same distinct-count semantics as Labels —
// `/api/v1/config.count.people` is the number of named subjects PP
// has clustered, surfaced eagerly without a separate /subjects fetch.
if (cat === 'people') return configQuery.data?.count?.people;
if (cat === 'ratings') return ratingsCount; if (cat === 'ratings') return ratingsCount;
return colorsCount; return colorsCount;
} }
@@ -576,14 +587,15 @@
]; ];
// Total badge for the "Tags" header row. Rolls up labels + keywords + // Total badge for the "Tags" header row. Rolls up labels + keywords +
// ratings + colors. Labels flows through countPhotos (scoped); keywords/ // people + ratings + colors. Labels flows through countPhotos (scoped);
// ratings/colors are library-wide marks tables and only contribute when // keywords/people/ratings/colors are library-wide and only contribute
// we're in admin-without-BasePath mode (their sources don't scope). // when we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => { const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined; if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge; if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0; const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount; const people = configQuery.data?.count?.people ?? 0;
return labelsBadge + keywords + people + ratingsCount + colorsCount;
}); });
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
@@ -1002,6 +1014,17 @@
> >
<Settings class="h-3.5 w-3.5" /> <Settings class="h-3.5 w-3.5" />
</button> </button>
{#if isAdminUser}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (usersOpen = true)}
title="Users"
aria-label="Manage users"
>
<Users class="h-3.5 w-3.5" />
</button>
{/if}
<button <button
type="button" type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1020,3 +1043,6 @@
open={generalSettingsOpen} open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)} onClose={() => (generalSettingsOpen = false)}
/> />
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}

View File

@@ -14,6 +14,7 @@
import { import {
cancelImport, cancelImport,
cancelIndex, cancelIndex,
getConfig,
getErrors, getErrors,
getSettings, getSettings,
saveSettings, saveSettings,
@@ -24,6 +25,7 @@
type PpLogEntry, type PpLogEntry,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/photoprism';
import { userBasePath } from '$lib/stores/session.svelte'; import { userBasePath } from '$lib/stores/session.svelte';
interface Props { interface Props {
@@ -34,7 +36,7 @@
const qc = useQueryClient(); const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library'); let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
// ── Library tab ─────────────────────────────────────────────────────── // ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them // Pull settings only while the dialog is open so we don't keep them
@@ -46,22 +48,42 @@
enabled: open enabled: open
})); }));
let draft = $state<PpSettings | null>(null); /**
$effect(() => { * Force the shape on every clone so each `bind:value={draft.index!.*}`
if (settingsQuery.data && draft === null) { * etc. has a real object to write into. Older PhotoPrism versions
draft = structuredClone(settingsQuery.data); * 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 ?? {}
};
} }
});
// Reset the draft when the dialog closes so the next open re-reads. 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(() => { $effect(() => {
if (!open) draft = null; if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
}); });
const saveMut = createMutation(() => ({ const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch), mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => { onSuccess: (next) => {
qc.setQueryData(['settings'], next); qc.setQueryData(['settings'], next);
draft = structuredClone(next); draft = normalize(structuredClone(next));
toast.success('Settings saved'); toast.success('Settings saved');
}, },
onError: (err) => onError: (err) =>
@@ -69,7 +91,7 @@
})); }));
function resetDraft() { function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data); if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
} }
// ── Index tab ───────────────────────────────────────────────────────── // ── Index tab ─────────────────────────────────────────────────────────
@@ -120,6 +142,64 @@
enabled: open && activeTab === 'logs', enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false 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> </script>
<Dialog.Root <Dialog.Root
@@ -157,7 +237,7 @@
<Tabs.List <Tabs.List
class="mb-3 flex gap-1 border-b border-border" 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 <Tabs.Trigger
value={t} 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" 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 Disable downloads entirely
</label> </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> </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>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
@@ -399,6 +563,130 @@
</div> </div>
</Tabs.Content> </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 --> <!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none"> <Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">

View File

@@ -0,0 +1,484 @@
<!--
Admin-only user management. PhotoPrism exposes /api/v1/users CRUD; this
dialog wraps it in a list/edit two-pane so the PP web UI never has to be
opened for routine user changes. Mounted from LeftSidebar's footer
(visible only when session.user.Role === 'admin').
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, Plus, Trash2, Users as UsersIcon, X } from 'lucide-svelte';
import {
createUser,
deleteUser,
listUsers,
setUserPassword,
updateUser,
type CreateUserBody
} from '$lib/services/photoprism';
import type { PpRole, PpUser } from '$lib/types/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
const usersQuery = createQuery<PpUser[]>(() => ({
queryKey: ['users'],
queryFn: listUsers,
enabled: open
}));
// Selection state: a UID picks an existing user from the list; `null`
// means "no selection" (right pane empty); `'new'` opens the new-user
// form. Reset whenever the dialog opens so reopening doesn't strand a
// stale form.
type Selection = string | 'new' | null;
let selection = $state<Selection>(null);
$effect(() => {
if (open) selection = null;
});
const ROLES: PpRole[] = ['admin', 'user', 'contributor', 'guest', 'visitor'];
// Editable copy of the selected user. Re-cloned on every selection
// change so the form starts from the server-side snapshot (and a
// failed save doesn't leak stale values into the next selection).
let draft = $state<EditableUser>(emptyDraft());
interface EditableUser {
UID: string;
Name: string;
DisplayName: string;
Email: string;
Role: PpRole;
BasePath: string;
UploadPath: string;
WebDAV: boolean;
Password: string;
}
function emptyDraft(): EditableUser {
return {
UID: '',
Name: '',
DisplayName: '',
Email: '',
Role: 'user',
BasePath: '',
UploadPath: '',
WebDAV: false,
Password: ''
};
}
function userToDraft(u: PpUser): EditableUser {
return {
UID: u.UID,
Name: u.Name ?? '',
DisplayName: u.DisplayName ?? '',
Email: u.Email ?? '',
Role: u.Role ?? 'user',
BasePath: u.BasePath ?? '',
UploadPath: u.UploadPath ?? '',
// Server may or may not return WebDAV depending on PP version;
// default to false rather than guessing the current value.
WebDAV: Boolean((u as PpUser & { WebDAV?: boolean }).WebDAV),
Password: ''
};
}
$effect(() => {
if (selection === 'new') {
draft = emptyDraft();
} else if (selection) {
const u = (usersQuery.data ?? []).find((x) => x.UID === selection);
if (u) draft = userToDraft(u);
} else {
draft = emptyDraft();
}
});
// Password sub-form (only relevant when editing an existing user).
// Decoupled from `draft` because the password endpoint is a separate
// PUT and never goes through createUser/updateUser.
let pwNew = $state('');
let pwConfirm = $state('');
$effect(() => {
// Reset password fields whenever the selection changes.
void selection;
pwNew = '';
pwConfirm = '';
});
function toBody(d: EditableUser): CreateUserBody {
const body: CreateUserBody = {
Name: d.Name.trim(),
Role: d.Role
};
if (d.DisplayName.trim()) body.DisplayName = d.DisplayName.trim();
if (d.Email.trim()) body.Email = d.Email.trim();
if (d.BasePath.trim()) body.BasePath = d.BasePath.trim();
if (d.UploadPath.trim()) body.UploadPath = d.UploadPath.trim();
body.WebDAV = d.WebDAV;
return body;
}
const createMut = createMutation(() => ({
mutationFn: async () => {
if (!draft.Name.trim()) throw new Error('Username is required');
if (!draft.Password || draft.Password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const body = toBody(draft);
body.Password = draft.Password;
return createUser(body);
},
onSuccess: (u) => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success(`Created user ${u.Name}`);
selection = u.UID;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create user')
}));
const updateMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return updateUser(selection, toBody(draft));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update user')
}));
const deleteMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return deleteUser(selection);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User deleted');
selection = null;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not delete user')
}));
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
if (pwNew.length < 8) throw new Error('Password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
// Admin-issued password reset: PhotoPrism accepts an empty `old`
// when the caller is an admin acting on another user.
await setUserPassword(selection, '', pwNew);
},
onSuccess: () => {
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
function onDeleteClick() {
if (!draft.Name) return;
if (!confirm(`Delete user "${draft.Name}"? This cannot be undone.`)) return;
deleteMut.mutate();
}
const isSelf = $derived(
selection !== 'new' && selection !== null && selection === session.user?.UID
);
const inputClass =
'rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring';
</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-[760px] -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">
<UsersIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">Users</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Manage accounts. Roles + per-user library paths come from the
server's ACL — changes apply immediately.
</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>
<div class="grid grid-cols-[220px_1fr] gap-4">
<!-- Left pane: user list + new-user trigger. -->
<div class="flex max-h-[480px] flex-col overflow-hidden rounded border border-border">
<button
type="button"
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-2 text-left text-[12px] hover:bg-accent"
class:bg-accent={selection === 'new'}
onclick={() => (selection = 'new')}
>
<Plus class="h-3.5 w-3.5" />
<span>New user</span>
</button>
<div class="flex-1 overflow-y-auto">
{#if usersQuery.isPending}
<p class="px-2 py-2 text-[12px] text-muted-foreground">Loading…</p>
{:else if usersQuery.isError}
<p class="px-2 py-2 text-[12px] text-destructive">
Could not load users.
</p>
{:else}
<ul>
{#each usersQuery.data ?? [] as u (u.UID)}
{@const active = selection === u.UID}
<button
type="button"
class="flex w-full flex-col gap-0.5 border-b border-border/40 px-2 py-1.5 text-left text-[12px] hover:bg-accent"
class:bg-accent={active}
onclick={() => (selection = u.UID)}
>
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium">
{u.DisplayName?.trim() || u.Name}
</span>
<span
class="shrink-0 rounded bg-secondary px-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
{u.Role}
</span>
</span>
{#if u.BasePath}
<span class="truncate text-[11px] text-muted-foreground">
{u.BasePath}
</span>
{/if}
</button>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Right pane: edit form for selected user (or empty/new form). -->
<div class="min-w-0">
{#if selection === null}
<div
class="flex h-full min-h-[280px] items-center justify-center rounded border border-dashed border-border p-4 text-center text-[12px] text-muted-foreground"
>
Pick a user on the left, or click "New user" to create one.
</div>
{:else}
<form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
if (selection === 'new') createMut.mutate();
else updateMut.mutate();
}}
>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Username
<span class="text-destructive">*</span>
</span>
<input
type="text"
bind:value={draft.Name}
required
autocomplete="off"
disabled={selection !== 'new'}
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Display name</span>
<input
type="text"
bind:value={draft.DisplayName}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Email</span>
<input
type="email"
bind:value={draft.Email}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Role</span>
<select bind:value={draft.Role} class={inputClass}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Base path
</span>
<input
type="text"
bind:value={draft.BasePath}
placeholder="e.g. alice"
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Upload path
</span>
<input
type="text"
bind:value={draft.UploadPath}
autocomplete="off"
class={inputClass}
/>
</label>
</div>
<label class="flex items-center gap-2 text-[12px]">
<input type="checkbox" bind:checked={draft.WebDAV} />
Allow WebDAV access
</label>
{#if selection === 'new'}
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Initial password <span class="text-destructive">*</span>
</span>
<input
type="password"
bind:value={draft.Password}
required
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
{/if}
<div class="flex items-center justify-between gap-2 border-t border-border pt-3">
{#if selection !== 'new'}
<button
type="button"
class="flex items-center gap-1.5 rounded border border-destructive/40 px-3 py-1 text-[12px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
onclick={onDeleteClick}
disabled={isSelf || deleteMut.isPending}
title={isSelf ? 'Cannot delete yourself' : 'Delete user'}
>
{#if deleteMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{:else}
<Trash2 class="h-3 w-3" />
{/if}
Delete
</button>
{:else}
<span></span>
{/if}
<button
type="submit"
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"
disabled={createMut.isPending || updateMut.isPending || !draft.Name.trim()}
>
{#if createMut.isPending || updateMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{selection === 'new' ? 'Create user' : 'Save changes'}
</button>
</div>
</form>
{#if selection !== 'new'}
<!-- Admin-issued password reset. Separate from the user's own
password change in GeneralSettingsDialog (which requires
their current password); admins reset without old-pw. -->
<form
class="mt-4 space-y-3 rounded border border-border bg-muted/30 p-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h4 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Reset password
</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">New password</span>
<input
type="password"
bind:value={pwNew}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Confirm</span>
<input
type="password"
bind:value={pwConfirm}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="flex items-center gap-1.5 rounded border border-border px-3 py-1 text-[12px] hover:bg-accent disabled:opacity-50"
disabled={pwMut.isPending || pwNew.length < 8 || pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Set password
</button>
</div>
</form>
{/if}
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -5,9 +5,11 @@
getAllMarks, getAllMarks,
listLabels, listLabels,
listPhotos, listPhotos,
listSubjects,
type AggregatedKeyword, type AggregatedKeyword,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpLabel,
type PpSubject
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
@@ -20,7 +22,7 @@
} from '$lib/utils/tagGroups'; } from '$lib/utils/tagGroups';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag } from 'lucide-svelte'; import { Hash, Tag, User } from 'lucide-svelte';
interface Props { interface Props {
category: TagCategory; category: TagCategory;
@@ -56,6 +58,12 @@
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({ const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'], queryKey: ['marks'],
queryFn: getAllMarks, queryFn: getAllMarks,
@@ -98,6 +106,20 @@
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q)); return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
}); });
const subjectsSorted = $derived(
[...(subjectsQuery.data ?? [])].sort(
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
)
);
const filteredSubjects = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return subjectsSorted;
return subjectsSorted.filter(
(s) =>
s.Name.toLowerCase().includes(q) || s.Slug.toLowerCase().includes(q)
);
});
const ratingGroups = $derived( const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data) buildRatingGroups(marksQuery.data, marksPoolQuery.data)
); );
@@ -124,8 +146,10 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount)); const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount)); const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length); const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length); const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
function loadMore() { function loadMore() {
visibleCount += PAGE_SIZE; visibleCount += PAGE_SIZE;
@@ -140,6 +164,9 @@
function pickKeyword(value: string) { function pickKeyword(value: string) {
if (selectedValue !== value) onSelect(value); if (selectedValue !== value) onSelect(value);
} }
function pickPerson(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickColor(key: string) { function pickColor(key: string) {
if (selectedValue !== key) onSelect(key); if (selectedValue !== key) onSelect(key);
} }
@@ -161,6 +188,9 @@
if (category === 'keywords') { if (category === 'keywords') {
return keywordsSorted[0]?.keyword ?? null; return keywordsSorted[0]?.keyword ?? null;
} }
if (category === 'people') {
return subjectsSorted[0]?.Slug ?? null;
}
if (category === 'colors') { if (category === 'colors') {
return colorGroups[0]?.key ?? null; return colorGroups[0]?.key ?? null;
} }
@@ -189,12 +219,16 @@
? 'Labels' ? 'Labels'
: category === 'keywords' : category === 'keywords'
? 'Keywords' ? 'Keywords'
: category === 'people'
? 'People'
: category === 'colors' : category === 'colors'
? 'Colors' ? 'Colors'
: 'Ratings' : 'Ratings'
); );
const showFilterInput = $derived(category === 'labels' || category === 'keywords'); const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
@@ -341,6 +375,74 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{:else if category === 'people'}
{#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load people" />
{:else if filteredSubjects.length === 0}
<EmptyState
size="compact"
icon={User}
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleSubjects as subject (subject.UID ?? subject.Slug)}
{@const active = subject.Slug === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickPerson(subject.Slug)}
title={subject.Name}
>
{#if subject.Thumb}
<img
src={thumbUrl(subject.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-secondary"
>
<User class="h-3 w-3 text-muted-foreground" />
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{subject.Name}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{subject.PhotoCount ?? 0}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreSubjects,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreSubjects}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredSubjects.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'} {:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p> <p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>

View File

@@ -13,6 +13,7 @@ import { primaryFile } from '$lib/types/photoprism';
import type { import type {
PpClientConfig, PpClientConfig,
PpPhoto, PpPhoto,
PpRole,
PpSessionResponse, PpSessionResponse,
PpUser PpUser
} from '$lib/types/photoprism'; } from '$lib/types/photoprism';
@@ -525,6 +526,39 @@ export async function listLabels(): Promise<PpLabel[]> {
return data; return data;
} }
// ── Subjects (people / face recognition) ────────────────────────────────────
//
// PhotoPrism's face indexer clusters detected faces into Subjects, each with a
// stable UID, a human-editable Name, and a slug. The DSL operator `person:<slug>`
// filters photos to those carrying a marker assigned to that subject.
export interface PpSubject {
UID: string;
Slug: string;
Name: string;
Favorite?: boolean;
Private?: boolean;
Excluded?: boolean;
PhotoCount?: number;
Thumb?: string;
}
export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
params: { count: 1000, order: 'count' }
});
return data ?? [];
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
const { data } = await http.put<PpSubject>(`/subjects/${uid}`, patch);
return data;
}
export async function deleteSubject(uid: string): Promise<void> {
await http.delete(`/subjects/${uid}`);
}
// ── Albums = Heaps ─────────────────────────────────────────────────────────── // ── Albums = Heaps ───────────────────────────────────────────────────────────
export interface PpAlbum { export interface PpAlbum {
@@ -831,7 +865,15 @@ export interface PpSettings {
showCaptions?: boolean; showCaptions?: boolean;
}; };
maps?: { animate?: number; style?: string }; maps?: { animate?: number; style?: string };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean }; index?: {
path?: string;
convert?: boolean;
rescan?: boolean;
skipArchived?: boolean;
skipMeta?: boolean;
skipRaw?: boolean;
skipHidden?: boolean;
};
import?: { path?: string; move?: boolean; dest?: string }; import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean }; stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: { download?: {
@@ -840,6 +882,41 @@ export interface PpSettings {
originals?: boolean; originals?: boolean;
mediaRaw?: boolean; mediaRaw?: boolean;
mediaSidecar?: boolean; mediaSidecar?: boolean;
crc32?: boolean;
sha1?: boolean;
};
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
}; };
[k: string]: unknown; [k: string]: unknown;
} }
@@ -907,6 +984,55 @@ export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEnt
return data ?? []; return data ?? [];
} }
// ── Users ────────────────────────────────────────────────────────────────────
//
// PhotoPrism's admin user endpoints. List/create/update/delete require an
// admin session; the password endpoint accepts the user's own UID with their
// current password as `old`.
export interface CreateUserBody {
Name: string;
DisplayName?: string;
Email?: string;
Role: PpRole;
BasePath?: string;
UploadPath?: string;
WebDAV?: boolean;
Password?: string;
}
export type UpdateUserBody = Partial<CreateUserBody>;
export async function listUsers(): Promise<PpUser[]> {
const { data } = await http.get<PpUser[] | { users?: PpUser[] }>('/users', {
params: { count: 1000, order: 'name' }
});
if (Array.isArray(data)) return data;
return data.users ?? [];
}
export async function createUser(body: CreateUserBody): Promise<PpUser> {
const { data } = await http.post<PpUser>('/users', body);
return data;
}
export async function updateUser(uid: string, patch: UpdateUserBody): Promise<PpUser> {
const { data } = await http.put<PpUser>(`/users/${uid}`, patch);
return data;
}
export async function deleteUser(uid: string): Promise<void> {
await http.delete(`/users/${uid}`);
}
export async function setUserPassword(
uid: string,
oldPassword: string,
newPassword: string
): Promise<void> {
await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword });
}
// ── Re-exports ─────────────────────────────────────────────────────────────── // ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser }; export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

View File

@@ -17,11 +17,12 @@ export type Section =
| 'hidden' | 'hidden'
| 'heap'; | 'heap';
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings'; export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export const TAG_CATEGORIES: readonly TagCategory[] = [ export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels', 'labels',
'keywords', 'keywords',
'people',
'colors', 'colors',
'ratings' 'ratings'
] as const; ] as const;
@@ -176,12 +177,15 @@ export function filtersToQ(f: FilterState = filters): string {
} }
// Tag drill-down clauses for server-resolvable tag categories. // Tag drill-down clauses for server-resolvable tag categories.
// Colors/ratings live in the mule-sidecar marks store and are // Colors/ratings live in the mule-sidecar marks store and are
// applied client-side after the photo pool is fetched. // applied client-side after the photo pool is fetched. People uses
// PhotoPrism's `person:` operator, which accepts the subject's slug.
if (f.tagCategory && f.tagValue) { if (f.tagCategory && f.tagValue) {
if (f.tagCategory === 'labels') { if (f.tagCategory === 'labels') {
parts.push(`label:${quoteIfNeeded(f.tagValue)}`); parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'keywords') { } else if (f.tagCategory === 'keywords') {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`); parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'people') {
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
} }
} }
if (f.search) parts.push(quoteIfNeeded(f.search)); if (f.search) parts.push(quoteIfNeeded(f.search));

View File

@@ -6,8 +6,10 @@
getPhoto, getPhoto,
listLabels, listLabels,
listPhotos, listPhotos,
listSubjects,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpLabel,
type PpSubject
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
filtersToQ, filtersToQ,
@@ -65,7 +67,9 @@
// filter on top would make drill counts disagree with the badges (a // filter on top would make drill counts disagree with the badges (a
// label badge of 157 could otherwise drill into 0 photos because the // label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them). // session is scoped to a folder that has none of them).
const useServer = $derived(category === 'labels' || category === 'keywords'); const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
const drillQ = $derived( const drillQ = $derived(
useServer && selectedValue useServer && selectedValue
? filtersToQ({ ? filtersToQ({
@@ -128,6 +132,11 @@
queryFn: listLabels, queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels' enabled: isAuthenticated() && category === 'labels'
})); }));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const drillTitle = $derived.by(() => { const drillTitle = $derived.by(() => {
if (!selectedValue) return ''; if (!selectedValue) return '';
if (category === 'labels') { if (category === 'labels') {
@@ -137,6 +146,10 @@
return hit?.Name ?? selectedValue; return hit?.Name ?? selectedValue;
} }
if (category === 'keywords') return selectedValue; if (category === 'keywords') return selectedValue;
if (category === 'people') {
const hit = (subjectsQuery.data ?? []).find((s) => s.Slug === selectedValue);
return hit?.Name ?? selectedValue;
}
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10)); if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
if (category === 'colors') { if (category === 'colors') {
return ( return (