- /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>
347 lines
10 KiB
Svelte
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>
|