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

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