Files
mule-image/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
dtoro 246d159d93 feat(metadata,people): richer metadata editing + face-naming flow
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>
2026-07-04 18:35:27 +02:00

367 lines
11 KiB
Svelte

<!--
Multi-select metadata panel. Mirrors mule-image's RightSidebar bulk mode:
apply the same Note / Date / Keyword to every selected photo.
Apply-button-driven (not blur-on-edit) so the user controls when the
mutation fans out — accidental focus loss won't rewrite N photos.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Calendar, Star, Tag } from 'lucide-svelte';
import {
buildTakenAtPatch,
bulkSetMarks,
isValidISODate,
type PhotoMark,
type PhotoMarksMap,
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { patchTargets, invalidateFacets } from '$lib/services/bulk';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
const qc = useQueryClient();
interface Props {
ids: string[];
}
let { ids }: Props = $props();
let noteDraft = $state('');
let dateDraft = $state('');
let keywordDraft = $state('');
// `null` = nothing picked yet; `0` / `''` = explicit clear.
let ratingDraft = $state<number | null>(null);
let colorDraft = $state<string | null>(null);
let busy = $state(false);
// `label` drives the per-photo tile overlay (pending → done / error) via the
// shared bulkAction store, so metadata applies show the same progress state
// as the archive/keep actions in BulkActionBar.
async function withBusy<T>(fn: () => Promise<T>, label?: string): Promise<T> {
busy = true;
if (label) startBulk(`${label}…`, ids);
try {
const result = await fn();
if (label) doneBulk(label, ids);
return result;
} catch (e) {
if (label) failBulk(ids);
throw e;
} finally {
busy = false;
}
}
async function applyNote() {
if (busy) return;
const value = noteDraft;
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
label,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
),
label
);
noteDraft = '';
}
const dateDraftValid = $derived(dateDraft === '' || isValidISODate(dateDraft));
async function applyDate() {
if (busy || !dateDraft || !isValidISODate(dateDraft)) return;
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`;
const label = `Date → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
// Per-photo patch so each photo keeps its own UTC↔local
// offset when the date is stamped across a selection.
(p) => buildTakenAtPatch(iso, p),
label,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt, p)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label
);
dateDraft = '';
}
async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return;
const tid = toast.loading(`${label}…`);
startBulk(`${label}…`, ids);
await withBusy(async () => {
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) };
for (const id of ids) {
const merged: PhotoMark = { ...(map[id] ?? {}), ...patch };
if (!merged.rating) delete merged.rating;
if (!merged.color) delete merged.color;
if (merged.rating == null && !merged.color) delete map[id];
else map[id] = merged;
}
return map;
});
try {
await bulkSetMarks(ids, patch);
doneBulk(label, ids);
// Refresh the Colors / Ratings facet panels — they sit on
// `['marks']` + `['photos','marks-pool']`, not the optimistic write above.
invalidateFacets();
toast.success(`${label} · ${ids.length}`, { id: tid });
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
void qc.invalidateQueries({ queryKey: ['marks'] });
}
});
}
async function applyRating() {
if (ratingDraft === null) return;
const value = ratingDraft;
await applyMarks({ rating: value }, value === 0 ? 'Cleared score' : `★ ${value}`);
ratingDraft = null;
}
async function applyColor() {
if (colorDraft === null) return;
const value = colorDraft;
await applyMarks({ color: value }, value ? `Color ${value}` : 'Cleared color');
colorDraft = null;
}
async function applyKeyword() {
if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, '');
if (!kw) return;
keywordDraft = '';
const label = `Tagged "${kw}" → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
(p) => {
const cur = (p.Details?.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
if (cur.includes(kw)) return {};
const next = [...cur, kw].join(', ');
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
},
label,
(p) => ({
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
),
label
);
}
function onKeywordKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
void applyKeyword();
}
}
</script>
<aside class="space-y-4 p-3 text-xs">
<header class="border-b border-border pb-2">
<div class="text-sm font-medium text-foreground">{ids.length} selected</div>
<p class="mt-0.5 text-[10px] text-muted-foreground">
Edits apply to every selected photo.
</p>
</header>
<!-- Note (Caption) -->
<section class="space-y-1">
<div
class="flex items-center justify-between text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span>Note</span>
<span class="font-normal normal-case text-muted-foreground/70">
Overwrites each photo
</span>
</div>
<textarea
rows="3"
placeholder="Add a note for all selected…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={noteDraft}
disabled={busy}
></textarea>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={applyNote}
>
Apply note to {ids.length}
</button>
</section>
<!-- Date (TakenAt) -->
<section class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Calendar class="h-3 w-3" /> Date taken
</div>
<input
type="text"
inputmode="numeric"
placeholder="YYYY-MM-DD"
pattern="\d{4}-\d{2}-\d{2}"
aria-invalid={!dateDraftValid}
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
bind:value={dateDraft}
disabled={busy}
/>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || !dateDraft || !dateDraftValid}
onclick={applyDate}
>
Apply date to {ids.length}
</button>
</section>
<!-- Score (rating) — pick a value with the stars, then Apply. The "Clear"
button picks `0` so the Apply step explicitly wipes the score across
the selection. Stored on the mule-sidecar; PhotoPrism's PUT can't
persist these. -->
<section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Score">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={ratingDraft !== null && ratingDraft >= n}
class:text-muted-foreground={!(ratingDraft !== null && ratingDraft >= n)}
disabled={busy}
onclick={() => (ratingDraft = n)}
title={`Pick ★ ${n}`}
aria-label={`Score ${n}`}
>
<Star
class="h-4 w-4"
fill={ratingDraft !== null && ratingDraft >= n ? 'currentColor' : 'none'}
/>
</button>
{/each}
<button
type="button"
class="ml-1 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
class:bg-accent={ratingDraft === 0}
class:text-foreground={ratingDraft === 0}
disabled={busy}
onclick={() => (ratingDraft = 0)}
title="Pick: clear score"
>
</button>
</div>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || ratingDraft === null}
onclick={applyRating}
>
{#if ratingDraft === null}
Pick a score
{:else if ratingDraft === 0}
Clear score on {ids.length}
{:else}
Apply ★ {ratingDraft} to {ids.length}
{/if}
</button>
</section>
<!-- Colors — same pattern as Score. -->
<section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = colorDraft === c.key}
<button
type="button"
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
disabled={busy}
onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`}
aria-label={`Color ${c.key}`}
></button>
{/each}
<button
type="button"
class="ml-0.5 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
class:bg-accent={colorDraft === ''}
class:text-foreground={colorDraft === ''}
disabled={busy}
onclick={() => (colorDraft = '')}
title="Pick: clear color"
>
</button>
</div>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || colorDraft === null}
onclick={applyColor}
>
{#if colorDraft === null}
Pick a color
{:else if colorDraft === ''}
Clear color on {ids.length}
{:else}
Apply {colorDraft} to {ids.length}
{/if}
</button>
</section>
<!-- Keywords (additive — merge into each photo's existing list) -->
<section class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Tag class="h-3 w-3" /> Add keyword
</div>
<input
type="text"
placeholder="tag name + Enter"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
disabled={busy}
onkeydown={onKeywordKeydown}
/>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || !keywordDraft.trim()}
onclick={applyKeyword}
>
Add to {ids.length}
</button>
<p class="text-[10px] text-muted-foreground/80">
Adds to existing keywords; doesn't replace them.
</p>
</section>
{#if busy}
<div class="text-[10px] text-muted-foreground">Applying…</div>
{/if}
</aside>