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:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
484
web/src/lib/components/layout/UsersDialog.svelte
Normal file
484
web/src/lib/components/layout/UsersDialog.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user