feat(review): fast keyboard-first Stacks & Duplicates resolve queue

Both tabs get a resolve-and-advance queue instead of independent
click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a
group removes it optimistically and auto-advances focus, and a sticky
header tracks reclaimable bytes + a running "resolved this session"
tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the
usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires
its own).

Sidecar (handlers_dups.go, fs.go, main.go):
- POST /duplicates/restore — inverse of /duplicates/archive, moves
  quarantined files back to their original path with the same BasePath
  guards and async reindex-with-cleanup.
- Scan results now include each file's mtime so the UI can label
  older/newer copies.

Stack losers now go through the same sidecar quarantine as
cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead
of a hard PhotoPrism DELETE, so both tabs share one recoverable,
undoable resolution path (services/duplicateActions.svelte.ts).

StackGroupCard: comparison-first — fact rows highlight the best
size/resolution per file, a "Suggested" badge appears when one file
wins outright, and Space opens a fullscreen CompareLightbox that flips
between candidates while preserving zoom/pan (extracted the zoom/pan
gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts
action so both consumers share one implementation).

CrossFolderGroupCard: since every copy is byte-identical, the old grid
of N identical thumbnails told the user nothing — replaced with one
thumbnail plus a path list that highlights the differing folder
segment and flags the indexed/newest copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 11:00:36 +02:00
parent 0f65bfb94a
commit 75008f238a
13 changed files with 1211 additions and 519 deletions

View File

@@ -16,6 +16,7 @@
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
@@ -103,88 +104,12 @@
}
// ── Zoom & pan ───────────────────────────────────────────────────────
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
// sharp image scale together. Resets on photo change. Past 1.25× the
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
const MAX_ZOOM = 6;
let zoom = $state(1);
let tx = $state(0);
let ty = $state(0);
let zoomHost = $state<HTMLElement | undefined>();
let panning = $state(false);
let lastX = 0;
let lastY = 0;
$effect(() => {
void uid;
zoom = 1;
tx = 0;
ty = 0;
});
function applyZoom(next: number, clientX: number, clientY: number) {
if (!zoomHost) return;
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
if (clamped === zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = zoomHost.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / zoom;
tx = cx + (tx - cx) * s;
ty = cy + (ty - cy) * s;
zoom = clamped;
if (zoom === 1) {
tx = 0;
ty = 0;
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
* so the listener is attached manually as non-passive. */
function wheelZoom(node: HTMLElement) {
node.addEventListener('wheel', onWheel, { passive: false });
return {
destroy() {
node.removeEventListener('wheel', onWheel);
}
};
}
function onDblClickZoom(e: MouseEvent) {
if (zoom > 1) {
zoom = 1;
tx = 0;
ty = 0;
} else {
applyZoom(2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (zoom === 1) return;
panning = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning) return;
tx += e.clientX - lastX;
ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
}
function onPointerUp() {
panning = false;
}
// Gesture handling lives in the shared zoomPan action (also used by
// the duplicates compare lightbox). Transform lives on a wrapper so
// the LQIP layer and the sharp image scale together. Resets on photo
// change via resetKey. Past 1.25× the sharp <img> switches to
// fit_2048 so zoomed pixels stay crisp.
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -229,26 +154,19 @@
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={zoomHost}
use:wheelZoom
ondblclick={onDblClickZoom}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
? panning
use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="relative flex h-full w-full items-center justify-center"
class:transition-transform={!panning}
class:duration-150={!panning}
style="transform: translate({tx}px, {ty}px) scale({zoom});"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
@@ -268,7 +186,7 @@
/>
{/if}
<img
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
@@ -276,11 +194,11 @@
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zoom > 1}
{#if zp.zoom > 1}
<span
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
>
{Math.round(zoom * 100)}% · double-click to reset
{Math.round(zp.zoom * 100)}% · double-click to reset
</span>
{/if}
</div>