Files
mule-image/web/src/lib/components/duplicates/StackGroupCard.svelte
dtoro 8c2526d982 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>
2026-05-17 16:06:58 +02:00

290 lines
9.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>