diff --git a/web/src/lib/components/layout/GeneralSettingsDialog.svelte b/web/src/lib/components/layout/GeneralSettingsDialog.svelte index ede99fe..3d57b7a 100644 --- a/web/src/lib/components/layout/GeneralSettingsDialog.svelte +++ b/web/src/lib/components/layout/GeneralSettingsDialog.svelte @@ -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 - Preferences for this app and your PhotoPrism account. - Library admin lives under Folders → ⚙. + Preferences for Mulimage and your account. Library admin lives + under Folders → ⚙. - {#each ['ui', 'search', 'maps'] as const as t (t)} + {#each ['ui', 'search', 'maps', 'account'] as const as t (t)} {#if settingsQuery.isPending} -

Loading PhotoPrism settings…

+

Loading server settings…

{:else if settingsQuery.isError} -

Could not load PhotoPrism settings.

+

Could not load server settings.

{:else if draft}

- PhotoPrism UI + Server UI

+ + {#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined} +
+

+ Indexer advanced +

+ {#if draft.index?.skipMeta !== undefined} + + {/if} + {#if draft.index?.skipRaw !== undefined} + + {/if} + {#if draft.index?.skipHidden !== undefined} + + {/if} +
+ {/if} + + + {#if draft.features && Object.keys(draft.features).length > 0} +
+

+ Features +

+

+ Toggling a feature off hides it from PhotoPrism's own + UI and disables the underlying API surface. +

+
+ {#each Object.keys(draft.features).sort() as key (key)} + {#if typeof draft.features![key] === 'boolean'} + + {/if} + {/each} +
+
+ {/if} +
@@ -399,6 +563,130 @@
+ + + {#if configQuery.isPending} + + {:else if configQuery.isError || !configQuery.data} + + {:else} + {@const cfg = configQuery.data} +
+

+ Server +

+
+ PhotoPrism + {cfg.edition} {cfg.version} + Site + + {cfg.siteUrl || '—'} + + Auth mode + {cfg.mode} +
+
+ +
+

+ Features +

+
+ {#each envKnobs as knob (knob.envVar)} + + + {knob.label} + + {/each} +
+
+ + {#if cfg.count} +
+

+ Library +

+
+ {#each COUNT_ROWS as row (row.key)} + {@const v = cfg.count?.[row.key]} + {#if v !== undefined} + {row.label} + {v} + {/if} + {/each} +
+
+ {/if} + + +
+ + {#if envHelpOpen} +
+

+ These knobs aren't exposed through the API. Edit + .env + on the host and restart PhotoPrism: +

+
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
+
    + {#each envKnobs as knob (knob.envVar)} +
  • + {knob.envVar} + — + + {knob.on ? 'enabled' : 'disabled'} + +
  • + {/each} +
+ {#if cfg.ext?.oidc?.enabled} +

+ OIDC provider: + + {cfg.ext.oidc.provider ?? '—'} + +

+ {/if} +
+ {/if} +
+ {/if} +
+
diff --git a/web/src/lib/components/layout/UsersDialog.svelte b/web/src/lib/components/layout/UsersDialog.svelte new file mode 100644 index 0000000..e67b5c5 --- /dev/null +++ b/web/src/lib/components/layout/UsersDialog.svelte @@ -0,0 +1,484 @@ + + + + { + if (!o) onClose(); + }} +> + + + +
+ +
+ Users + + Manage accounts. Roles + per-user library paths come from the + server's ACL — changes apply immediately. + +
+ + + +
+ +
+ +
+ +
+ {#if usersQuery.isPending} +

Loading…

+ {:else if usersQuery.isError} +

+ Could not load users. +

+ {:else} +
    + {#each usersQuery.data ?? [] as u (u.UID)} + {@const active = selection === u.UID} + + {/each} +
+ {/if} +
+
+ + +
+ {#if selection === null} +
+ Pick a user on the left, or click "New user" to create one. +
+ {:else} +
{ + e.preventDefault(); + if (selection === 'new') createMut.mutate(); + else updateMut.mutate(); + }} + > +
+ + + + + + +
+ + + + {#if selection === 'new'} + + {/if} + +
+ {#if selection !== 'new'} + + {:else} + + {/if} + +
+
+ + {#if selection !== 'new'} + +
{ + e.preventDefault(); + pwMut.mutate(); + }} + > +

+ Reset password +

+
+ + +
+
+ +
+
+ {/if} + {/if} +
+
+
+
+
diff --git a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte index 5a488ed..936b43a 100644 --- a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte +++ b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte @@ -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(() => ({ + queryKey: ['subjects'], + queryFn: listSubjects, + enabled: isAuthenticated() && category === 'people' + })); + const marksQuery = createQuery(() => ({ 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' + );
@@ -341,6 +375,74 @@ {/if}
{/if} + {:else if category === 'people'} + {#if subjectsQuery.isPending} + + {:else if subjectsQuery.isError} + + {:else if filteredSubjects.length === 0} + + {:else} +
+ {#each visibleSubjects as subject (subject.UID ?? subject.Slug)} + {@const active = subject.Slug === selectedValue} + + {/each} + + {#if hasMoreSubjects} +

+ Loading more… ({visibleCount} / {filteredSubjects.length}) +

+ {/if} +
+ {/if} {:else if category === 'colors'} {#if marksQuery.isPending || marksPoolQuery.isPending}

Loading colors…

diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index dd3c5b8..b62c7b2 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -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 { 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:` +// 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 { + const { data } = await http.get('/subjects', { + params: { count: 1000, order: 'count' } + }); + return data ?? []; +} + +export async function updateSubject(uid: string, patch: Partial): Promise { + const { data } = await http.put(`/subjects/${uid}`, patch); + return data; +} + +export async function deleteSubject(uid: string): Promise { + 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; + +export async function listUsers(): Promise { + const { data } = await http.get('/users', { + params: { count: 1000, order: 'name' } + }); + if (Array.isArray(data)) return data; + return data.users ?? []; +} + +export async function createUser(body: CreateUserBody): Promise { + const { data } = await http.post('/users', body); + return data; +} + +export async function updateUser(uid: string, patch: UpdateUserBody): Promise { + const { data } = await http.put(`/users/${uid}`, patch); + return data; +} + +export async function deleteUser(uid: string): Promise { + await http.delete(`/users/${uid}`); +} + +export async function setUserPassword( + uid: string, + oldPassword: string, + newPassword: string +): Promise { + await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword }); +} + // ── Re-exports ─────────────────────────────────────────────────────────────── export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser }; diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts index 6b2013f..2d326cd 100644 --- a/web/src/lib/stores/filters.svelte.ts +++ b/web/src/lib/stores/filters.svelte.ts @@ -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)); diff --git a/web/src/routes/tags/[category]/[[value]]/+page.svelte b/web/src/routes/tags/[category]/[[value]]/+page.svelte index 230f7ad..7cb2117 100644 --- a/web/src/routes/tags/[category]/[[value]]/+page.svelte +++ b/web/src/routes/tags/[category]/[[value]]/+page.svelte @@ -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(() => ({ + 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 (