Files
mule-image/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
dtoro d70244f17e feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as
  additional tabs alongside cause tabs; /duplicates keeps a redirect
  for bookmarks.
- LeftSidebar: drop import/inbox tile and favorites; show per-user
  BasePath label at the folder root.
- RightSidebar: split file header into read-only path over editable
  basename (matches sidecar rename contract); date field switches to
  plain-text ISO YYYY-MM-DD (no native datetime picker) with strict
  validation and revert-on-invalid-blur; preserves original hour.
- BulkMetadataSidebar: same ISO-only date input with invalid-state
  styling and apply-button gating.
- BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still
  reachable via gridKeyNav.
- gridKeyNav: remove favorite toggle (F) alongside the favorites view
  retirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:48:38 +02:00

347 lines
10 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 } 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 = '';
}
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`;
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 — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
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="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>
<!-- 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>