3 Commits

Author SHA1 Message Date
a7b8a60473 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>
2026-05-20 08:26:12 +02:00
24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
e364e4128f web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
22 changed files with 1530 additions and 167 deletions

View File

@@ -333,7 +333,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
// truth. // truth.
if (added.length === 0) { if (added.length === 0) {
toast.error(`Nothing added to ${heap.Title}`, { toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).` description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
}); });
return; return;
} }

View File

@@ -158,7 +158,7 @@
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`, `Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{ {
description: losingIndexed description: losingIndexed
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.' ? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
: 'Files moved to .duplicates/ inside originals.' : 'Files moved to .duplicates/ inside originals.'
} }
); );
@@ -246,7 +246,7 @@
{#if isIndexed} {#if isIndexed}
<span <span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white" class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently indexed by PhotoPrism" title="Currently in the library"
> >
Indexed Indexed
</span> </span>

View File

@@ -30,6 +30,8 @@
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte'; import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte'; import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder'; type Tab = 'stacks' | 'cross-folder';
@@ -75,20 +77,24 @@
{#if activeTab === 'stacks'} {#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6"> <div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending} {#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p> <InlineLoader label="Loading stacks…" />
{:else if error} {:else if error}
<p class="text-sm text-destructive"> <EmptyState
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'} tone="destructive"
</p> icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <EmptyState icon={Copy} title="No stacks">
<p>No stacks.</p> {#snippet descriptionSnippet()}
<p class="text-xs"> <p>
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index any, this tab stays empty. Cross-folder copies dropped at index time live under
time live under the Cross-folder tab. the Cross-folder tab.
</p> </p>
</div> {/snippet}
</EmptyState>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each groups as group, i (group.photo.UID)} {#each groups as group, i (group.photo.UID)}
@@ -104,7 +110,7 @@
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6"> <div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3"> <header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground"> <p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by scanning the Byte-identical files the indexer dropped at index time. Found by scanning the
originals tree directly. originals tree directly.
</p> </p>
<button <button
@@ -122,22 +128,26 @@
</header> </header>
{#if crossQuery.isFetching && !crossQuery.data} {#if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">Hashing files under originals…</p> <InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError} {:else if crossQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Scan failed: {crossQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message ? crossQuery.error.message
: 'unknown error'} : 'unknown error'}
</p> />
{:else if crossCount === 0} {:else if crossCount === 0}
<p class="text-sm text-muted-foreground"> <EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
No cross-folder duplicates found. {#snippet descriptionSnippet()}
{#if crossQuery.data} {#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70"> <p class="text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms) scanned in {crossQuery.data.scannedMs} ms
</span> </p>
{/if} {/if}
</p> {/snippet}
</EmptyState>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)} {#each crossQuery.data?.groups ?? [] as group, i (group.hash)}

View File

@@ -0,0 +1,84 @@
<!--
Shared empty / no-data placeholder. Doubles as an error display when
`tone="destructive"` (swaps colors and announces with role=alert).
Use `size="compact"` inside sidebars where vertical space is tight.
-->
<script lang="ts">
import type { Component, Snippet } from 'svelte';
interface Props {
icon?: Component<any> | any;
title: string;
description?: string;
descriptionSnippet?: Snippet;
align?: 'left' | 'center';
tone?: 'muted' | 'destructive';
size?: 'compact' | 'default';
children?: Snippet;
}
let {
icon: Icon,
title,
description,
descriptionSnippet,
align,
tone = 'muted',
size = 'default',
children
}: Props = $props();
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
const isDestructive = $derived(tone === 'destructive');
</script>
{#if size === 'compact'}
<div
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
? 'items-center justify-center text-center'
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
{/if}
<div class="min-w-0">
<span>{title}</span>
{#if descriptionSnippet}
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
{:else if description}
<div class="mt-0.5 opacity-80">{description}</div>
{/if}
{#if children}
<div class="mt-1.5">{@render children()}</div>
{/if}
</div>
</div>
{:else}
<div
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
? 'items-center text-center'
: 'items-start text-left'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
aria-hidden="true"
/>
{/if}
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
{#if descriptionSnippet}
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
{@render descriptionSnippet()}
</div>
{:else if description}
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
{/if}
{#if children}
<div class="mt-2">{@render children()}</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,39 @@
<!--
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
in sidebars, popovers, and right rails. For the initial photo-grid load,
use SkeletonGrid instead (layout-preserving).
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
label?: string;
size?: 'sm' | 'default';
align?: 'left' | 'center';
srOnly?: boolean;
polite?: boolean;
}
let {
label = 'Loading…',
size = 'default',
align = 'left',
srOnly = false,
polite = true
}: Props = $props();
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
</script>
<p
role="status"
aria-busy="true"
aria-live={polite ? 'polite' : 'off'}
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
>
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
</p>

View File

@@ -0,0 +1,2 @@
export { default as EmptyState } from './EmptyState.svelte';
export { default as InlineLoader } from './InlineLoader.svelte';

View File

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

View File

@@ -14,7 +14,8 @@
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { FolderInput, Loader2 } from 'lucide-svelte'; import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { import {
convertHeap, convertHeap,
listFolders, listFolders,
@@ -153,11 +154,14 @@
</div> </div>
<div class="max-h-[200px] overflow-y-auto"> <div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending} {#if foldersQuery.isPending}
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p> <InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0} {:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground"> <EmptyState
No folders. Create one from the sidebar first. size="compact"
</p> icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else} {:else}
<!-- Root row: lets the user drop the heap directly into <!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty originals/ without picking a subfolder. The empty

View File

@@ -51,18 +51,23 @@
import HeapConvertDialog from './HeapConvertDialog.svelte'; import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte'; import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import { import {
Copy, Copy,
Download, Download,
FolderInput, FolderInput,
FolderOpen,
FolderPlus, FolderPlus,
Layers,
LogOut, LogOut,
Moon, Moon,
Pencil, Pencil,
Settings, Settings,
Sun, Sun,
Trash2 Trash2,
Users
} from 'lucide-svelte'; } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -348,6 +353,10 @@
// admin dialog above — opened from the bottom-of-sidebar footer. // admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false); let generalSettingsOpen = $state(false);
// Admin-only user management dialog. Footer icon is gated on
// `isAdminUser` so non-admins never see the entry point.
let usersOpen = $state(false);
// Root-folder collapse state. Persisted to its own localStorage key so // Root-folder collapse state. Persisted to its own localStorage key so
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults // it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree. // to open so first-time users see the full tree.
@@ -382,6 +391,7 @@
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = { const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels', labels: 'Labels',
keywords: 'Keywords', keywords: 'Keywords',
people: 'People',
colors: 'Colors', colors: 'Colors',
ratings: 'Ratings' ratings: 'Ratings'
}; };
@@ -396,6 +406,10 @@
// Keywords sub-row's distinct-count semantics. // Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels; if (cat === 'labels') return configQuery.data?.count?.labels;
if (cat === 'keywords') return keywordsQuery.data?.length; if (cat === 'keywords') return keywordsQuery.data?.length;
// People follows the same distinct-count semantics as Labels —
// `/api/v1/config.count.people` is the number of named subjects PP
// has clustered, surfaced eagerly without a separate /subjects fetch.
if (cat === 'people') return configQuery.data?.count?.people;
if (cat === 'ratings') return ratingsCount; if (cat === 'ratings') return ratingsCount;
return colorsCount; return colorsCount;
} }
@@ -573,14 +587,15 @@
]; ];
// Total badge for the "Tags" header row. Rolls up labels + keywords + // Total badge for the "Tags" header row. Rolls up labels + keywords +
// ratings + colors. Labels flows through countPhotos (scoped); keywords/ // people + ratings + colors. Labels flows through countPhotos (scoped);
// ratings/colors are library-wide marks tables and only contribute when // keywords/people/ratings/colors are library-wide and only contribute
// we're in admin-without-BasePath mode (their sources don't scope). // when we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => { const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined; if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge; if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0; const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount; const people = configQuery.data?.count?.people ?? 0;
return labelsBadge + keywords + people + ratingsCount + colorsCount;
}); });
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
@@ -757,9 +772,9 @@
</div> </div>
</div> </div>
{#if foldersQuery.isPending} {#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading folders…" />
{:else if !hasSubfolders} {:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p> <EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{:else if rootExpanded} {:else if rootExpanded}
<!-- <!--
depth=1 visually nests the top-level subfolders one indent depth=1 visually nests the top-level subfolders one indent
@@ -795,11 +810,11 @@
</div> </div>
{#if heapsQuery.isPending} {#if heapsQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading heaps…" />
{:else if heapsQuery.isError} {:else if heapsQuery.isError}
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p> <EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
{:else if (heapsQuery.data ?? []).length === 0} {:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p> <EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else} {:else}
<ul> <ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)} {#each heapsQuery.data ?? [] as heap (heap.UID)}
@@ -999,6 +1014,17 @@
> >
<Settings class="h-3.5 w-3.5" /> <Settings class="h-3.5 w-3.5" />
</button> </button>
{#if isAdminUser}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (usersOpen = true)}
title="Users"
aria-label="Manage users"
>
<Users class="h-3.5 w-3.5" />
</button>
{/if}
<button <button
type="button" type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1017,3 +1043,6 @@
open={generalSettingsOpen} open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)} onClose={() => (generalSettingsOpen = false)}
/> />
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}

View File

@@ -9,10 +9,12 @@
import { Dialog, Tabs } from 'bits-ui'; import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte'; import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { import {
cancelImport, cancelImport,
cancelIndex, cancelIndex,
getConfig,
getErrors, getErrors,
getSettings, getSettings,
saveSettings, saveSettings,
@@ -23,6 +25,7 @@
type PpLogEntry, type PpLogEntry,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/photoprism';
import { userBasePath } from '$lib/stores/session.svelte'; import { userBasePath } from '$lib/stores/session.svelte';
interface Props { interface Props {
@@ -33,7 +36,7 @@
const qc = useQueryClient(); const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library'); let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
// ── Library tab ─────────────────────────────────────────────────────── // ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them // Pull settings only while the dialog is open so we don't keep them
@@ -45,22 +48,42 @@
enabled: open 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); let draft = $state<PpSettings | null>(null);
// Re-clone on every open so reopening shows the freshest server state.
// Resetting on open (not close) avoids the race where bits-ui's exit
// animation keeps the form mounted with `draft === null` and the
// `bind:value={draft.download!.originals}` getter throws.
$effect(() => { $effect(() => {
if (settingsQuery.data && draft === null) { if (open && settingsQuery.data) {
draft = structuredClone(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(() => ({ const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch), mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => { onSuccess: (next) => {
qc.setQueryData(['settings'], next); qc.setQueryData(['settings'], next);
draft = structuredClone(next); draft = normalize(structuredClone(next));
toast.success('Settings saved'); toast.success('Settings saved');
}, },
onError: (err) => onError: (err) =>
@@ -68,7 +91,7 @@
})); }));
function resetDraft() { function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data); if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
} }
// ── Index tab ───────────────────────────────────────────────────────── // ── Index tab ─────────────────────────────────────────────────────────
@@ -119,6 +142,64 @@
enabled: open && activeTab === 'logs', enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false refetchInterval: open && activeTab === 'logs' ? 5000 : false
})); }));
// ── About tab ─────────────────────────────────────────────────────────
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
// so the About tab never triggers an extra round-trip — config is
// already warm by the time the user opens this dialog.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: open && activeTab === 'about'
}));
// PhotoPrism's `flags` is a space-separated bag of feature toggles
// ("experimental tensorflow places webdav share download import oidc").
// Parse once so the chip grid can render in stable order.
const flagSet = $derived.by<Set<string>>(() => {
const raw = configQuery.data?.flags ?? '';
return new Set(raw.split(/\s+/).filter(Boolean));
});
// Env-driven knobs that don't have a runtime PP API. Listed here so the
// About tab can render a "you need to edit .env and restart" help
// section instead of pretending these are mutable from the UI.
interface EnvKnob {
envVar: string;
label: string;
on: boolean;
}
const envKnobs = $derived.by<EnvKnob[]>(() => {
const f = flagSet;
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
return [
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
];
});
// Show the config block collapsed by default — most users only want the
// version + counts; the env help is for the rare admin moment.
let envHelpOpen = $state(false);
// Library counts surfaced as a compact 2-column grid. Order matches
// what users care about most often (photos, then derived buckets).
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
{ key: 'all', label: 'Photos' },
{ key: 'videos', label: 'Videos' },
{ key: 'live', label: 'Live photos' },
{ key: 'favorites', label: 'Favorites' },
{ key: 'review', label: 'In review' },
{ key: 'archived', label: 'Archived' },
{ key: 'hidden', label: 'Hidden' },
{ key: 'people', label: 'People' },
{ key: 'labels', label: 'Labels' },
{ key: 'folders', label: 'Folders' },
{ key: 'albums', label: 'Albums' }
];
</script> </script>
<Dialog.Root <Dialog.Root
@@ -141,7 +222,7 @@
Library settings Library settings
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground"> <Dialog.Description class="mt-1 text-xs text-muted-foreground">
Drive PhotoPrism's library, indexer, importer and server log. Drive the library, indexer, importer and server log.
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close <Dialog.Close
@@ -156,7 +237,7 @@
<Tabs.List <Tabs.List
class="mb-3 flex gap-1 border-b border-border" class="mb-3 flex gap-1 border-b border-border"
> >
{#each ['library', 'index', 'import', 'logs'] as const as t (t)} {#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
<Tabs.Trigger <Tabs.Trigger
value={t} value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground" class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
@@ -272,8 +353,92 @@
/> />
Disable downloads entirely Disable downloads entirely
</label> </label>
{#if draft.download?.crc32 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.crc32}
/>
Include CRC32 checksum
</label>
{/if}
{#if draft.download?.sha1 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.sha1}
/>
Include SHA1 checksum
</label>
{/if}
</section> </section>
<!-- Indexer advanced — only renders the fields PP actually
reported. Older PP versions return a smaller `index`
block and we don't want to fabricate UI for missing keys. -->
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Indexer advanced
</h3>
{#if draft.index?.skipMeta !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipMeta}
/>
Skip metadata-only changes
</label>
{/if}
{#if draft.index?.skipRaw !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipRaw}
/>
Skip RAW files
</label>
{/if}
{#if draft.index?.skipHidden !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipHidden}
/>
Skip hidden files
</label>
{/if}
</section>
{/if}
<!-- Features — PhotoPrism's gating bag. Render only the
keys actually present in the response (PP version
drift), labelled human-readably. -->
{#if draft.features && Object.keys(draft.features).length > 0}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<p class="text-muted-foreground">
Toggling a feature off hides it from PhotoPrism's own
UI and disables the underlying API surface.
</p>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.keys(draft.features).sort() as key (key)}
{#if typeof draft.features![key] === 'boolean'}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.features![key]}
/>
<span class="capitalize">{key}</span>
</label>
{/if}
{/each}
</div>
</section>
{/if}
</div> </div>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
@@ -398,12 +563,136 @@
</div> </div>
</Tabs.Content> </Tabs.Content>
<!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
{#if configQuery.isPending}
<InlineLoader size="sm" label="Loading server info…" />
{:else if configQuery.isError || !configQuery.data}
<EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load server info"
/>
{:else}
{@const cfg = configQuery.data}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server
</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
<span class="text-muted-foreground">PhotoPrism</span>
<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
<span class="text-muted-foreground">Site</span>
<span class="truncate text-right" title={cfg.siteUrl}>
{cfg.siteUrl || '—'}
</span>
<span class="text-muted-foreground">Auth mode</span>
<span class="text-right">{cfg.mode}</span>
</div>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<div class="flex flex-wrap gap-1.5">
{#each envKnobs as knob (knob.envVar)}
<span
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
: 'border-border bg-secondary text-muted-foreground'}"
title={knob.envVar}
>
<span
class="h-1.5 w-1.5 rounded-full {knob.on
? 'bg-green-500'
: 'bg-muted-foreground/40'}"
></span>
{knob.label}
</span>
{/each}
</div>
</section>
{#if cfg.count}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
{#each COUNT_ROWS as row (row.key)}
{@const v = cfg.count?.[row.key]}
{#if v !== undefined}
<span class="text-muted-foreground">{row.label}</span>
<span class="text-right tabular-nums">{v}</span>
{/if}
{/each}
</div>
</section>
{/if}
<!-- Env-driven config: there is no PhotoPrism API for these.
The panel surfaces what's on/off and reminds the admin
where to flip the switch — .env + restart. -->
<section class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
onclick={() => (envHelpOpen = !envHelpOpen)}
aria-expanded={envHelpOpen}
>
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server configuration (env-driven)
</span>
<span class="text-[10px] text-muted-foreground">
{envHelpOpen ? '▾' : '▸'}
</span>
</button>
{#if envHelpOpen}
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
<p class="text-muted-foreground">
These knobs aren't exposed through the API. Edit
<code class="rounded bg-background px-1">.env</code>
on the host and restart PhotoPrism:
</p>
<pre
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
>docker compose up -d photoprism
# or, with podman-compose:
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
<ul class="space-y-0.5">
{#each envKnobs as knob (knob.envVar)}
<li>
<code class="rounded bg-background px-1">{knob.envVar}</code>
<span class:text-green-600={knob.on}
class:text-muted-foreground={!knob.on}>
{knob.on ? 'enabled' : 'disabled'}
</span>
</li>
{/each}
</ul>
{#if cfg.ext?.oidc?.enabled}
<p class="text-muted-foreground">
OIDC provider:
<span class="text-foreground">
{cfg.ext.oidc.provider ?? '—'}
</span>
</p>
{/if}
</div>
{/if}
</section>
{/if}
</Tabs.Content>
<!-- Logs — recent server errors --> <!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none"> <Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Most recent PhotoPrism errors and warnings. Auto-refreshes Most recent server errors and warnings. Auto-refreshes every
every 5 seconds. 5 seconds.
</p> </p>
<button <button
type="button" type="button"
@@ -418,11 +707,16 @@
</button> </button>
</div> </div>
{#if errorsQuery.isPending} {#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading error log…" />
{:else if errorsQuery.isError} {:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p> <EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load error log"
/>
{:else if (errorsQuery.data ?? []).length === 0} {:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p> <EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
{:else} {:else}
<ul <ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]" class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"

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

@@ -15,6 +15,8 @@
import { setAnchor, setFocused } from '$lib/stores/selection.svelte'; import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
interface Props { interface Props {
uid: string | null; uid: string | null;
@@ -58,11 +60,11 @@
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4"> <div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null} {#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p> <EmptyState icon={ImageIcon} title="Select a photo to preview" />
{:else if photoQuery.isPending} {:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p> <InlineLoader label="Loading photo…" align="center" />
{:else if photoQuery.isError} {:else if photoQuery.isError}
<p class="text-sm text-destructive">Failed to load photo.</p> <EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
{:else if photoQuery.data} {:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)} {@const pf = primaryFile(photoQuery.data)}
{#if showChevrons && currentIndex > 0} {#if showChevrons && currentIndex > 0}

View File

@@ -16,6 +16,7 @@
import { thumbUrl } from '$lib/stores/session.svelte'; import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte'; import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { Loader2 } from 'lucide-svelte';
interface Props { interface Props {
title: string; title: string;
@@ -62,7 +63,15 @@
</script> </script>
{#if stripQuery.isPending} {#if stripQuery.isPending}
<div class="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}</div> <div
class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2 class="h-2.5 w-2.5 animate-spin" aria-hidden="true" />
<span>Loading {title.toLowerCase()}</span>
</div>
{:else if stripQuery.isError} {:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. --> <!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null} {null}

View File

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

View File

@@ -22,6 +22,8 @@
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
const qc = useQueryClient(); const qc = useQueryClient();
let busy = $state(false); let busy = $state(false);
@@ -172,7 +174,7 @@
// a no-op. // a no-op.
if (added.length === 0) { if (added.length === 0) {
toast.error(`Nothing added to ${heap.Title}`, { toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).` description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
}); });
return; return;
} }
@@ -288,9 +290,9 @@
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg" class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
> >
{#if heapsQuery.isPending} {#if heapsQuery.isPending}
<p class="px-2 py-1 text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading heaps…" />
{:else if (heapsQuery.data ?? []).length === 0} {:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p> <EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else} {:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)} {#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button <button

View File

@@ -53,7 +53,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Implausible year', title: 'Implausible year',
chip: 'bad year', chip: 'bad year',
suggestion: suggestion:
"Filenames suggest a date PhotoPrism doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.", "Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
suggestedAction: 'manual' suggestedAction: 'manual'
}, },
non_image_type: { non_image_type: {
@@ -66,7 +66,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Other quality issues', title: 'Other quality issues',
chip: 'low quality', chip: 'low quality',
suggestion: suggestion:
'PhotoPrism flagged these but the metadata looks fine. Open the first one to investigate.', 'The indexer flagged these but the metadata looks fine. Open the first one to investigate.',
suggestedAction: 'manual' suggestedAction: 'manual'
} }
}; };

View File

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

View File

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

View File

@@ -56,6 +56,16 @@
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte"; import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte"; import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { EmptyState, InlineLoader } from "$lib/components/feedback";
import {
AlertCircle,
Archive,
EyeOff,
ImageOff,
Layers,
MousePointerClick,
Sparkles,
} from "lucide-svelte";
import { type PpPhoto } from "$lib/types/photoprism"; import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
@@ -831,29 +841,42 @@
{#if photosQuery.isPending} {#if photosQuery.isPending}
<SkeletonGrid /> <SkeletonGrid />
{:else if photosQuery.isError} {:else if photosQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Failed to load photos: {photosQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message ? photosQuery.error.message
: "unknown error"} : "unknown error"}
</p> />
{:else if photos.length === 0} {:else if photos.length === 0}
<p class="text-sm text-muted-foreground"> {#if filters.section === "archive"}
{#if filters.section === "archive"} <EmptyState icon={Archive} title="Archive is empty" />
Archive is empty. {:else if filters.section === "review"}
{:else if filters.section === "review"} <EmptyState
Nothing left to review. Photos PhotoPrism's indexer wasn't icon={Sparkles}
sure about land here — use Keep to accept them into the title="Nothing left to review"
timeline or Archive to set them aside. description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
{:else if filters.section === "hidden"} />
No hidden photos. PhotoPrism auto-hides files it can't index {:else if filters.section === "hidden"}
(broken files, very low quality); they only ever show up here. <EmptyState
{:else if filters.section === "heap"} icon={EyeOff}
This heap has no photos yet. Select some photos and use the title="No hidden photos"
bulk bar's " Add to heap" button. description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
{:else} />
No photos. Index a folder via PhotoPrism's reindex command. {:else if filters.section === "heap"}
{/if} <EmptyState
</p> icon={Layers}
title="This heap has no photos yet"
description={'Select some photos and use the bulk bars “+ Add to heap” button.'}
/>
{:else}
<EmptyState
icon={ImageOff}
title="No photos"
description="Index a folder from Settings → Index, or run a reindex from the server."
/>
{/if}
{:else} {:else}
<div <div
data-photo-grid data-photo-grid
@@ -920,9 +943,12 @@
}} }}
></div> ></div>
{#if photosQuery.isFetchingNextPage} {#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground"> <InlineLoader
Loading more size="sm"
</p> align="center"
polite={false}
label="Loading more photos…"
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -944,15 +970,16 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p> <InlineLoader size="sm" label="Loading metadata…" />
{:else} {:else}
<div class="space-y-2 p-4 text-center"> <EmptyState icon={MousePointerClick} title="No photo selected">
<div class="text-xl"></div> {#snippet descriptionSnippet()}
<p class="text-xs text-muted-foreground"> <p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
on a thumbnail to view its metadata here. >+click on a thumbnail to view its metadata here.
</p> </p>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</div> </div>
<!-- Resize handle on the left edge; mirrors the layout's left aside <!-- Resize handle on the left edge; mirrors the layout's left aside

View File

@@ -61,8 +61,8 @@
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm" class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
> >
<header class="space-y-1"> <header class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1> <h1 class="text-2xl font-semibold tracking-tight text-foreground">Mulimage</h1>
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p> <p class="text-sm text-muted-foreground">Sign in to your account.</p>
</header> </header>
<label class="block space-y-1.5"> <label class="block space-y-1.5">

View File

@@ -51,6 +51,8 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte'; import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Sparkles } from 'lucide-svelte';
type DupTab = 'stacks' | 'cross-folder'; type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab; type Tab = CauseKey | DupTab;
@@ -226,23 +228,27 @@
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending} {#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error} {:else if reviewQuery.error}
<p class="text-sm text-destructive"> <EmptyState
Could not load review queue: {reviewQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message ? reviewQuery.error.message
: 'unknown error'} : 'unknown error'}
</p> />
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <EmptyState icon={Sparkles} title="Nothing to review">
<p>The review queue is empty.</p> {#snippet descriptionSnippet()}
<p class="text-xs"> <p>
PhotoPrism's indexer flags photos with a low quality score for human The indexer flags photos with a low quality score for human review. New
review. New arrivals with missing EXIF, low resolution, or unknown arrivals with missing EXIF, low resolution, or unknown cameras will land
cameras will land here. The Stacks and Cross-folder tabs above stay here. The Stacks and Cross-folder tabs above stay available for
available for duplicate cleanup. duplicate cleanup.
</p> </p>
</div> {/snippet}
</EmptyState>
{:else if activeGroup} {:else if activeGroup}
{#key activeGroup.cause} {#key activeGroup.cause}
<CauseGroupCard group={activeGroup} /> <CauseGroupCard group={activeGroup} />
@@ -263,7 +269,7 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated /> <RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading metadata…" />
{/if} {/if}
</div> </div>
<div <div

View File

@@ -6,8 +6,10 @@
getPhoto, getPhoto,
listLabels, listLabels,
listPhotos, listPhotos,
listSubjects,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpLabel,
type PpSubject
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
filtersToQ, filtersToQ,
@@ -32,6 +34,8 @@
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, ImageOff, MousePointerClick, Tag } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
// URL-driven category + value. `isTagCategory` rejects typos so a stray // URL-driven category + value. `isTagCategory` rejects typos so a stray
@@ -63,7 +67,9 @@
// filter on top would make drill counts disagree with the badges (a // filter on top would make drill counts disagree with the badges (a
// label badge of 157 could otherwise drill into 0 photos because the // label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them). // session is scoped to a folder that has none of them).
const useServer = $derived(category === 'labels' || category === 'keywords'); const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
const drillQ = $derived( const drillQ = $derived(
useServer && selectedValue useServer && selectedValue
? filtersToQ({ ? filtersToQ({
@@ -126,6 +132,11 @@
queryFn: listLabels, queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels' enabled: isAuthenticated() && category === 'labels'
})); }));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const drillTitle = $derived.by(() => { const drillTitle = $derived.by(() => {
if (!selectedValue) return ''; if (!selectedValue) return '';
if (category === 'labels') { if (category === 'labels') {
@@ -135,6 +146,10 @@
return hit?.Name ?? selectedValue; return hit?.Name ?? selectedValue;
} }
if (category === 'keywords') return selectedValue; if (category === 'keywords') return selectedValue;
if (category === 'people') {
const hit = (subjectsQuery.data ?? []).find((s) => s.Slug === selectedValue);
return hit?.Name ?? selectedValue;
}
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10)); if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
if (category === 'colors') { if (category === 'colors') {
return ( return (
@@ -188,12 +203,11 @@
{#if !selectedValue} {#if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8"> <main class="flex min-h-0 flex-1 items-center justify-center p-8">
<div class="max-w-sm space-y-2 text-center"> <EmptyState
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p> icon={Tag}
<p class="text-xs text-muted-foreground"> title={`Pick a ${category ?? 'tag'} from the sidebar`}
Click a row in the panel on the left to filter the photo grid by that tag. description="Click a row in the panel on the left to filter the photo grid by that tag."
</p> />
</div>
</main> </main>
{:else} {:else}
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
@@ -205,9 +219,9 @@
{#if showSkeleton} {#if showSkeleton}
<SkeletonGrid /> <SkeletonGrid />
{:else if showError} {:else if showError}
<p class="text-sm text-destructive">Failed to load photos.</p> <EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photos" />
{:else if drillPhotos.length === 0} {:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p> <EmptyState icon={ImageOff} title="No photos under this tag" />
{:else} {:else}
<PhotoGrid photos={drillPhotos} /> <PhotoGrid photos={drillPhotos} />
{/if} {/if}
@@ -225,15 +239,16 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading metadata…" />
{:else} {:else}
<div class="space-y-2 p-4 text-center"> <EmptyState icon={MousePointerClick} title="No photo selected">
<div class="text-xl"></div> {#snippet descriptionSnippet()}
<p class="text-xs text-muted-foreground"> <p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here. thumbnail to view its metadata here.
</p> </p>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</div> </div>
<div <div