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>
367 lines
11 KiB
Svelte
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>
|