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 {
getSettings,
saveSettings,
setUserPassword,
type PpSettings
} from '$lib/services/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
@@ -27,7 +29,29 @@
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 = [
{ value: 'light', label: 'Light', Icon: Sun },
@@ -160,8 +184,8 @@
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app and your PhotoPrism account.
Library admin lives under Folders → ⚙.
Preferences for Mulimage and your account. Library admin lives
under Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
@@ -174,7 +198,7 @@
<Tabs.Root bind:value={activeTab}>
<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
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"
@@ -217,13 +241,13 @@
</section>
{#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}
<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}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism UI
Server UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
@@ -275,11 +299,11 @@
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'}
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui'}
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
@@ -332,6 +356,97 @@
</label>
</Tabs.Content>
{/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>
<!-- Datalist for time-zone autocomplete. Falls back to the
@@ -344,8 +459,9 @@
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save. -->
{#if draft}
only show the action row when there's something to save.
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">
<button
type="button"

View File

@@ -51,6 +51,7 @@
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
Copy,
Download,
@@ -63,7 +64,8 @@
Pencil,
Settings,
Sun,
Trash2
Trash2,
Users
} from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -351,6 +353,10 @@
// admin dialog above — opened from the bottom-of-sidebar footer.
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
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree.
@@ -385,6 +391,7 @@
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels',
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
};
@@ -399,6 +406,10 @@
// Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels;
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;
return colorsCount;
}
@@ -576,14 +587,15 @@
];
// Total badge for the "Tags" header row. Rolls up labels + keywords +
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
// ratings/colors are library-wide marks tables and only contribute when
// we're in admin-without-BasePath mode (their sources don't scope).
// people + ratings + colors. Labels flows through countPhotos (scoped);
// keywords/people/ratings/colors are library-wide and only contribute
// when we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge;
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[] = [
@@ -1002,6 +1014,17 @@
>
<Settings class="h-3.5 w-3.5" />
</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
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1020,3 +1043,6 @@
open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)}
/>
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}

View File

@@ -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">

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,
listLabels,
listPhotos,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
type PpLabel
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
@@ -20,7 +22,7 @@
} from '$lib/utils/tagGroups';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag } from 'lucide-svelte';
import { Hash, Tag, User } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -56,6 +58,12 @@
staleTime: 5 * 60_000
}));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -98,6 +106,20 @@
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(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -124,8 +146,10 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -140,6 +164,9 @@
function pickKeyword(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickPerson(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickColor(key: string) {
if (selectedValue !== key) onSelect(key);
}
@@ -161,6 +188,9 @@
if (category === 'keywords') {
return keywordsSorted[0]?.keyword ?? null;
}
if (category === 'people') {
return subjectsSorted[0]?.Slug ?? null;
}
if (category === 'colors') {
return colorGroups[0]?.key ?? null;
}
@@ -189,12 +219,16 @@
? 'Labels'
: category === 'keywords'
? 'Keywords'
: category === 'colors'
? 'Colors'
: 'Ratings'
: category === 'people'
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
);
const showFilterInput = $derived(category === 'labels' || category === 'keywords');
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
</script>
<div class="flex h-full min-h-0 flex-col">
@@ -341,6 +375,74 @@
{/if}
</div>
{/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'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<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 {
PpClientConfig,
PpPhoto,
PpRole,
PpSessionResponse,
PpUser
} from '$lib/types/photoprism';
@@ -525,6 +526,39 @@ export async function listLabels(): Promise<PpLabel[]> {
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 ───────────────────────────────────────────────────────────
export interface PpAlbum {
@@ -831,7 +865,15 @@ export interface PpSettings {
showCaptions?: boolean;
};
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 };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
@@ -840,6 +882,41 @@ export interface PpSettings {
originals?: boolean;
mediaRaw?: 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;
}
@@ -907,6 +984,55 @@ export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEnt
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 ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

View File

@@ -17,11 +17,12 @@ export type Section =
| 'hidden'
| 'heap';
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings';
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'people',
'colors',
'ratings'
] as const;
@@ -176,12 +177,15 @@ export function filtersToQ(f: FilterState = filters): string {
}
// Tag drill-down clauses for server-resolvable tag categories.
// 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 === 'labels') {
parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'keywords') {
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));

View File

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