feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
341
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
341
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
@@ -0,0 +1,341 @@
|
||||
<!--
|
||||
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,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
|
||||
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);
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
busy = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyNote() {
|
||||
if (busy) return;
|
||||
const value = noteDraft;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
)
|
||||
);
|
||||
noteDraft = '';
|
||||
}
|
||||
|
||||
async function applyDate() {
|
||||
if (busy || !dateDraft) return;
|
||||
// datetime-local omits the timezone; treat the input as UTC (same
|
||||
// convention as the single-photo sidebar) and let PhotoPrism's
|
||||
// backwrite stamp the local timezone field downstream.
|
||||
const iso = `${dateDraft}:00Z`;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
`Date → ${ids.length}`,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
)
|
||||
);
|
||||
dateDraft = '';
|
||||
}
|
||||
|
||||
async function applyMarks(patch: PhotoMark, label: string) {
|
||||
if (busy) return;
|
||||
await withBusy(async () => {
|
||||
// Optimistic: patch every selected photo's mark in the local
|
||||
// cache before round-tripping. Sidecar bulk endpoint is
|
||||
// authoritative; on failure we just invalidate so the next
|
||||
// list query overrides.
|
||||
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);
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
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;
|
||||
}
|
||||
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||
];
|
||||
|
||||
async function applyKeyword() {
|
||||
if (busy) return;
|
||||
const kw = keywordDraft.trim().replace(/,/g, '');
|
||||
if (!kw) return;
|
||||
keywordDraft = '';
|
||||
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' } };
|
||||
},
|
||||
`Tagged "${kw}" → ${ids.length}`,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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="datetime-local"
|
||||
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={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}
|
||||
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>
|
||||
|
||||
<!-- Color label — same pattern as Score. -->
|
||||
<section class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
|
||||
class:ring-foreground={colorDraft === c.key}
|
||||
class:ring-transparent={colorDraft !== c.key}
|
||||
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>
|
||||
Reference in New Issue
Block a user