Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
fixing videoFile(): PhotoPrism serializes MediaType as the bare word
"video", so the old startsWith('video/') check never matched and the
helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
(per-photo in bulk edits) so PhotoPrism can't clobber manual date
edits when recomputing from TimeZone, and Year/Month/Day now derive
from local wall-clock time.
People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
markers→files→photos SQL pass (labels pattern), replacing the
client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
{Name, SubjSrc:manual}, verified against PP source) which creates
the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
per-marker ownership checks (was blanket-forbidden, which would have
blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
linking to the person's page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
3.8 KiB
Svelte
113 lines
3.8 KiB
Svelte
<!--
|
|
"Name new faces" — the missing half of the People feature. PhotoPrism
|
|
only creates a person once someone names a detected face cluster, so a
|
|
library can have tens of thousands of face markers and still show an
|
|
empty People list. This panel surfaces the caller's unnamed clusters
|
|
(scoped server-side to their BasePath) as face-crop cards with an
|
|
inline name input; naming goes through PhotoPrism's own flow (PUT on
|
|
the cluster's representative marker), which creates the Subject and
|
|
propagates it across the cluster.
|
|
-->
|
|
<script lang="ts">
|
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
|
import { toast } from 'svelte-sonner';
|
|
import {
|
|
listUnnamedFaces,
|
|
nameFaceCluster,
|
|
type UnnamedFaceCluster
|
|
} from '$lib/services/photoprism';
|
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
|
import { InlineLoader } from '$lib/components/feedback';
|
|
import { UserPlus } from 'lucide-svelte';
|
|
|
|
const qc = useQueryClient();
|
|
|
|
const facesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
|
|
queryKey: ['faces', 'unnamed'],
|
|
queryFn: listUnnamedFaces,
|
|
enabled: isAuthenticated(),
|
|
staleTime: 60_000
|
|
}));
|
|
|
|
let drafts = $state<Record<string, string>>({});
|
|
let busy = $state<Record<string, boolean>>({});
|
|
|
|
async function submit(cluster: UnnamedFaceCluster) {
|
|
const name = (drafts[cluster.faceId] ?? '').trim();
|
|
if (!name || busy[cluster.faceId]) return;
|
|
busy[cluster.faceId] = true;
|
|
try {
|
|
await nameFaceCluster(cluster.markerUid, name);
|
|
toast.success(`Named ${name}`, {
|
|
description:
|
|
'PhotoPrism links the whole cluster in the background — the photo count may keep growing.'
|
|
});
|
|
drafts[cluster.faceId] = '';
|
|
void qc.invalidateQueries({ queryKey: ['faces', 'unnamed'] });
|
|
void qc.invalidateQueries({ queryKey: ['subjects'] });
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Naming failed');
|
|
} finally {
|
|
busy[cluster.faceId] = false;
|
|
}
|
|
}
|
|
|
|
const clusters = $derived(facesQuery.data ?? []);
|
|
</script>
|
|
|
|
{#if facesQuery.isPending}
|
|
<InlineLoader label="Looking for unnamed faces…" />
|
|
{:else if clusters.length > 0}
|
|
<section class="space-y-3">
|
|
<header class="space-y-0.5">
|
|
<h2 class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
<UserPlus class="h-4 w-4" /> Name new faces
|
|
</h2>
|
|
<p class="text-[11px] text-muted-foreground">
|
|
Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags
|
|
every matching photo.
|
|
</p>
|
|
</header>
|
|
<div
|
|
class="grid gap-3"
|
|
style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));"
|
|
>
|
|
{#each clusters as cluster (cluster.faceId)}
|
|
<div
|
|
class="flex flex-col items-center gap-2 rounded-md border border-border bg-card/30 p-3"
|
|
>
|
|
<div class="relative">
|
|
<img
|
|
src={thumbUrl(cluster.thumb, 'tile_224')}
|
|
alt="Unnamed face"
|
|
loading="lazy"
|
|
decoding="async"
|
|
class="h-20 w-20 rounded-full border border-border object-cover"
|
|
/>
|
|
<span
|
|
class="absolute -bottom-1 -right-1 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground"
|
|
title={`${cluster.count} of your photos carry this face`}
|
|
>
|
|
{cluster.count}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Name…"
|
|
disabled={busy[cluster.faceId]}
|
|
class="w-full rounded border border-input bg-background px-1.5 py-1 text-center text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
|
bind:value={drafts[cluster.faceId]}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void submit(cluster);
|
|
}
|
|
}}
|
|
onblur={() => void submit(cluster)}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|