web: admin surfaces so the PhotoPrism UI is never needed

- Account tab in General settings — self-service password change.
- UsersDialog (admin-only footer entry) — full /api/v1/users CRUD with
  admin-issued password reset.
- People as a fifth tag category alongside Labels/Keywords/Colors/Ratings,
  backed by /api/v1/subjects and the `person:` DSL clause.
- About tab in Library settings — version, library counts, feature chips,
  and a collapsible env-config help panel for the bits PP has no runtime
  API for (OIDC, TF, WebDAV).
- Library tab expanded with Indexer-advanced, extra Downloads checksums,
  and a Features grid that only renders keys PhotoPrism actually returns.
- Fix the SettingsDialog null-draft race the same way GeneralSettingsDialog
  already had: normalize on open, never null on close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 08:25:53 +02:00
parent 24dfa996b3
commit a7b8a60473
8 changed files with 1196 additions and 37 deletions

View File

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