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>
290 lines
9.6 KiB
Svelte
290 lines
9.6 KiB
Svelte
<!--
|
||
One duplicate stack rendered as a card. Each variant file is a clickable
|
||
tile; clicking selects it as the candidate "best". Committing promotes
|
||
the selected file to Primary (via `setPrimary`) and deletes the rest from
|
||
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
|
||
files/:fid` route).
|
||
|
||
Why DELETE instead of unstack-then-archive (which the plan started with):
|
||
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
|
||
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
|
||
pairs. DELETE works for all of them — and cascades through the live-
|
||
photo group automatically, so one click resolves the whole stack. The
|
||
on-disk file is renamed with a hash suffix (not erased), so a future
|
||
manual reindex can recover it if needed.
|
||
|
||
Keyboard:
|
||
- Section is tabindex=0; focusing it captures arrow keys + Enter.
|
||
- Left/Right move the "best" highlight one file; Up/Down move by the
|
||
grid's computed column count (same trick the timeline uses for
|
||
cross-row arrow nav).
|
||
- Enter commits the current selection. Esc removes focus from the card.
|
||
- The page's first card auto-focuses on mount so the user can drive
|
||
the workflow keyboard-first.
|
||
-->
|
||
<script lang="ts">
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { toast } from 'svelte-sonner';
|
||
import { deleteFile, setPrimary } from '$lib/services/photoprism';
|
||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||
import { view } from '$lib/stores/view.svelte';
|
||
|
||
interface Props {
|
||
group: DuplicateGroup;
|
||
/** When true, the section auto-focuses on mount so the user can
|
||
* arrow-key/Enter the workflow without reaching for the mouse.
|
||
* Only the page's first card should get this. */
|
||
autoFocus?: boolean;
|
||
}
|
||
let { group, autoFocus = false }: Props = $props();
|
||
|
||
const qc = useQueryClient();
|
||
let best = $state('');
|
||
let busy = $state(false);
|
||
let sectionEl: HTMLElement | undefined = $state();
|
||
let gridEl: HTMLElement | undefined = $state();
|
||
let cols = $state(1);
|
||
|
||
$effect(() => {
|
||
// Seed / re-seed `best` from the prop when the underlying group
|
||
// changes (keyed each + UID key normally keeps this stable, but
|
||
// the guard handles prop swaps without overwriting user clicks).
|
||
if (!best || !group.files.some((f) => f.UID === best)) {
|
||
best = group.bestFileUid;
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||
});
|
||
|
||
// Track the grid's column count via ResizeObserver — same approach
|
||
// the timeline uses. Reading `gridTemplateColumns` from computed
|
||
// style is O(1) regardless of how many tiles render.
|
||
$effect(() => {
|
||
if (!gridEl) return;
|
||
const measure = () => {
|
||
if (!gridEl) return;
|
||
const n = getComputedStyle(gridEl)
|
||
.gridTemplateColumns.split(' ')
|
||
.filter(Boolean).length;
|
||
cols = Math.max(1, n);
|
||
};
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(gridEl);
|
||
return () => ro.disconnect();
|
||
});
|
||
// thumbnailSize changes alter cols without resizing the grid; re-
|
||
// measure on the next microtask.
|
||
$effect(() => {
|
||
void view.thumbnailSize;
|
||
queueMicrotask(() => {
|
||
if (!gridEl) return;
|
||
const n = getComputedStyle(gridEl)
|
||
.gridTemplateColumns.split(' ')
|
||
.filter(Boolean).length;
|
||
cols = Math.max(1, n);
|
||
});
|
||
});
|
||
|
||
function shortPath(name: string): string {
|
||
const segs = name.split('/').filter(Boolean);
|
||
if (segs.length <= 2) return name;
|
||
return '…/' + segs.slice(-2).join('/');
|
||
}
|
||
|
||
function dims(f: { Width?: number; Height?: number }): string {
|
||
if (!f.Width || !f.Height) return '';
|
||
return `${f.Width}×${f.Height}`;
|
||
}
|
||
|
||
function sizeLabel(bytes?: number): string {
|
||
if (!bytes) return '';
|
||
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||
return `${Math.round(bytes / 1024)} KB`;
|
||
}
|
||
|
||
function moveBest(delta: number) {
|
||
const i = group.files.findIndex((f) => f.UID === best);
|
||
if (i < 0) return;
|
||
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
|
||
best = group.files[next].UID;
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (busy) return;
|
||
switch (e.key) {
|
||
case 'ArrowLeft':
|
||
e.preventDefault();
|
||
moveBest(-1);
|
||
return;
|
||
case 'ArrowRight':
|
||
e.preventDefault();
|
||
moveBest(1);
|
||
return;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
moveBest(-cols);
|
||
return;
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
moveBest(cols);
|
||
return;
|
||
case 'Enter':
|
||
e.preventDefault();
|
||
void commit();
|
||
return;
|
||
case 'Escape':
|
||
(e.target as HTMLElement)?.blur();
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function commit() {
|
||
if (busy || group.files.length < 2) return;
|
||
busy = true;
|
||
const photoUid = group.photo.UID;
|
||
const losers = group.files.filter((f) => f.UID !== best);
|
||
try {
|
||
// 1. Promote the user's pick to Primary first (idempotent — if
|
||
// it's already Primary, the call is a no-op on the server).
|
||
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
|
||
if (best !== currentPrimary) {
|
||
await setPrimary(photoUid, best);
|
||
}
|
||
// 2. Delete each non-best file. PhotoPrism cascades through
|
||
// related variants in the same logical group (live-photo
|
||
// pairs, sidecar companions), so a single DELETE on one
|
||
// HEIC variant clears the whole HEIC+MOV pair in one go.
|
||
// Loop tolerates partial success — if PhotoPrism already
|
||
// cleared the file via cascade, the next DELETE 404s and
|
||
// we move on.
|
||
for (const f of losers) {
|
||
try {
|
||
await deleteFile(photoUid, f.UID);
|
||
} catch (err) {
|
||
// 404 means the file's already gone (cascade) — fine.
|
||
// Any other status means we have a real problem; bubble it.
|
||
const status = (err as { response?: { status?: number } })?.response
|
||
?.status;
|
||
if (status !== 404) throw err;
|
||
}
|
||
}
|
||
toast.success(`Resolved · kept 1 of ${group.files.length}`);
|
||
void qc.invalidateQueries({ queryKey: ['duplicates'] });
|
||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||
} catch (err) {
|
||
const msg =
|
||
err instanceof Error && err.message ? err.message : 'Resolve failed';
|
||
toast.error(msg);
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
|
||
none` because we paint our own focus ring on .focus-visible below
|
||
(otherwise the browser default outline would clash with the tile
|
||
selection ring). -->
|
||
<!--
|
||
`role="application"` declares this as a custom keyboard widget (arrow
|
||
keys + Enter, not standard reading order). The element below is a
|
||
`<div>` rather than `<section>` because Svelte's a11y linter treats
|
||
`<section>` as strictly non-interactive even with an explicit
|
||
application role.
|
||
-->
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
bind:this={sectionEl}
|
||
tabindex="0"
|
||
role="application"
|
||
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
|
||
onkeydown={onKeydown}
|
||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||
>
|
||
<header class="flex items-center justify-between gap-3">
|
||
<div class="min-w-0">
|
||
<div class="text-sm font-medium text-foreground">
|
||
{group.files.length} files in this stack
|
||
</div>
|
||
<div class="truncate text-xs text-muted-foreground">
|
||
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||
disabled={busy || group.files.length < 2}
|
||
onclick={commit}
|
||
title="Promote the selected file and delete the rest from this stack"
|
||
>
|
||
Keep selected, delete rest
|
||
<kbd
|
||
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||
>Enter</kbd
|
||
>
|
||
</button>
|
||
</header>
|
||
|
||
<div
|
||
bind:this={gridEl}
|
||
class="grid gap-2"
|
||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||
>
|
||
{#each group.files as file (file.UID)}
|
||
{@const isBest = file.UID === best}
|
||
{@const sizeStr = sizeLabel(file.Size)}
|
||
<button
|
||
type="button"
|
||
onclick={() => (best = file.UID)}
|
||
class:scale-95={isBest}
|
||
class:ring-2={isBest}
|
||
class:ring-blue-500={isBest}
|
||
class:ring-offset-2={isBest}
|
||
class:ring-offset-background={isBest}
|
||
class:transition-[transform,box-shadow]={isBest}
|
||
class:duration-300={isBest}
|
||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
|
||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||
>
|
||
<div class="relative aspect-square w-full overflow-hidden">
|
||
<img
|
||
src={thumbUrl(file.Hash, 'tile_500')}
|
||
alt={file.Name}
|
||
loading="lazy"
|
||
class="h-full w-full object-cover"
|
||
/>
|
||
{#if isBest}
|
||
<span
|
||
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||
>
|
||
Best
|
||
</span>
|
||
{/if}
|
||
{#if dims(file)}
|
||
<span
|
||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||
>
|
||
{dims(file)}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<div
|
||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||
>
|
||
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
|
||
{#if sizeStr}
|
||
<div>{sizeStr}</div>
|
||
{/if}
|
||
</div>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|