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:
130
web/src/lib/actions/zoomPan.ts
Normal file
130
web/src/lib/actions/zoomPan.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Wheel-zoom + drag-pan for an image container. Extracted from
|
||||
* PreviewPane so the compare lightbox can share the exact gesture
|
||||
* behavior: wheel zooms around the cursor, double-click toggles
|
||||
* 1 ↔ dblClickZoom, dragging pans while zoomed.
|
||||
*
|
||||
* The action owns the event listeners (wheel must be non-passive for
|
||||
* preventDefault; Svelte marks template wheel handlers passive) and
|
||||
* reports state through `onChange`. The consumer applies the transform
|
||||
* to an inner wrapper:
|
||||
*
|
||||
* <div use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}>
|
||||
* <div style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});">…
|
||||
*
|
||||
* `resetKey` resets to 1:1 whenever it changes (e.g. per photo). Keep
|
||||
* it constant to preserve zoom/pan across content swaps — that's what
|
||||
* makes pixel-compare flipping work in the lightbox.
|
||||
*/
|
||||
|
||||
export interface ZoomPanState {
|
||||
zoom: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
panning: boolean;
|
||||
}
|
||||
|
||||
export interface ZoomPanParams {
|
||||
onChange: (state: ZoomPanState) => void;
|
||||
/** Reset to 1:1 when this value changes. */
|
||||
resetKey?: unknown;
|
||||
maxZoom?: number;
|
||||
dblClickZoom?: number;
|
||||
}
|
||||
|
||||
export function zoomPan(node: HTMLElement, params: ZoomPanParams) {
|
||||
let current = params;
|
||||
const state: ZoomPanState = { zoom: 1, tx: 0, ty: 0, panning: false };
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
function emit() {
|
||||
current.onChange({ ...state });
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.zoom = 1;
|
||||
state.tx = 0;
|
||||
state.ty = 0;
|
||||
state.panning = false;
|
||||
emit();
|
||||
}
|
||||
|
||||
function applyZoom(next: number, clientX: number, clientY: number) {
|
||||
const max = current.maxZoom ?? 6;
|
||||
const clamped = Math.min(max, Math.max(1, next));
|
||||
if (clamped === state.zoom) return;
|
||||
// Keep the point under the cursor fixed: translate offsets are in
|
||||
// post-scale pixels around the container centre.
|
||||
const rect = node.getBoundingClientRect();
|
||||
const cx = clientX - rect.left - rect.width / 2;
|
||||
const cy = clientY - rect.top - rect.height / 2;
|
||||
const s = clamped / state.zoom;
|
||||
state.tx = cx + (state.tx - cx) * s;
|
||||
state.ty = cy + (state.ty - cy) * s;
|
||||
state.zoom = clamped;
|
||||
if (state.zoom === 1) {
|
||||
state.tx = 0;
|
||||
state.ty = 0;
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
applyZoom(state.zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function onDblClick(e: MouseEvent) {
|
||||
if (state.zoom > 1) {
|
||||
reset();
|
||||
} else {
|
||||
applyZoom(current.dblClickZoom ?? 2.5, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (state.zoom === 1) return;
|
||||
state.panning = true;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
node.setPointerCapture(e.pointerId);
|
||||
emit();
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!state.panning) return;
|
||||
state.tx += e.clientX - lastX;
|
||||
state.ty += e.clientY - lastY;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
emit();
|
||||
}
|
||||
function onPointerUp() {
|
||||
if (!state.panning) return;
|
||||
state.panning = false;
|
||||
emit();
|
||||
}
|
||||
|
||||
node.addEventListener('wheel', onWheel, { passive: false });
|
||||
node.addEventListener('dblclick', onDblClick);
|
||||
node.addEventListener('pointerdown', onPointerDown);
|
||||
node.addEventListener('pointermove', onPointerMove);
|
||||
node.addEventListener('pointerup', onPointerUp);
|
||||
node.addEventListener('pointercancel', onPointerUp);
|
||||
|
||||
return {
|
||||
update(next: ZoomPanParams) {
|
||||
const keyChanged = next.resetKey !== current.resetKey;
|
||||
current = next;
|
||||
if (keyChanged) reset();
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', onWheel);
|
||||
node.removeEventListener('dblclick', onDblClick);
|
||||
node.removeEventListener('pointerdown', onPointerDown);
|
||||
node.removeEventListener('pointermove', onPointerMove);
|
||||
node.removeEventListener('pointerup', onPointerUp);
|
||||
node.removeEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
};
|
||||
}
|
||||
156
web/src/lib/components/duplicates/CompareLightbox.svelte
Normal file
156
web/src/lib/components/duplicates/CompareLightbox.svelte
Normal file
@@ -0,0 +1,156 @@
|
||||
<!--
|
||||
Fullscreen pixel-compare overlay for a duplicate stack. Shows one
|
||||
candidate at a time at fit_2048; ←/→ flip between candidates while
|
||||
PRESERVING zoom & pan (the whole point — zoom into an eye or a hair,
|
||||
then flip to see which file is sharper). Enter picks the shown file
|
||||
as the keeper and closes; Esc closes without picking.
|
||||
|
||||
All candidate <img>s stay mounted (stacks are 2–5 files) with only
|
||||
the active one visible, so flips are instant once loaded and the
|
||||
shared transform wrapper keeps them aligned.
|
||||
|
||||
Keys are intercepted at window-capture level while open so the group
|
||||
card / global shortcuts underneath don't also react.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
|
||||
import type { PpFile } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
files: PpFile[];
|
||||
/** UID of the candidate shown first. */
|
||||
startUid: string;
|
||||
onPick: (uid: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { files, startUid, onPick, onClose }: Props = $props();
|
||||
|
||||
let index = $state(0);
|
||||
$effect.pre(() => {
|
||||
const i = files.findIndex((f) => f.UID === startUid);
|
||||
index = i >= 0 ? i : 0;
|
||||
});
|
||||
|
||||
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
|
||||
|
||||
const active = $derived(files[index]);
|
||||
|
||||
function flip(delta: number) {
|
||||
index = (index + delta + files.length) % files.length;
|
||||
}
|
||||
|
||||
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 onKeydown(e: KeyboardEvent) {
|
||||
// Swallow everything except modifier combos so the card / global
|
||||
// shortcuts underneath stay inert while the lightbox is up.
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
flip(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
flip(1);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
onPick(active.UID);
|
||||
return;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
default: {
|
||||
const n = Number.parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= files.length) {
|
||||
e.preventDefault();
|
||||
index = n - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col bg-black/90"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Compare stack files"
|
||||
>
|
||||
<!-- Caption / controls bar -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2 text-xs text-white/90">
|
||||
<div class="min-w-0 truncate font-mono">{active?.Name ?? ''}</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
{#if active?.Width && active?.Height}
|
||||
<span>{active.Width}×{active.Height}</span>
|
||||
{/if}
|
||||
{#if active?.Size}
|
||||
<span>{sizeLabel(active.Size)}</span>
|
||||
{/if}
|
||||
<span class="text-white/60">{index + 1} / {files.length}</span>
|
||||
{#if zp.zoom > 1}
|
||||
<span class="text-white/60">{Math.round(zp.zoom * 100)}%</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
|
||||
onclick={() => onPick(active.UID)}
|
||||
>
|
||||
Keep this <kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Enter</kbd>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
|
||||
onclick={onClose}
|
||||
aria-label="Close compare view"
|
||||
>
|
||||
✕ <kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Esc</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image stage — shared transform so flips stay pixel-aligned -->
|
||||
<div
|
||||
use:zoomPan={{ onChange: (s) => (zp = s) }}
|
||||
class="relative min-h-0 flex-1 overflow-hidden {zp.zoom > 1
|
||||
? zp.panning
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
: 'cursor-zoom-in'}"
|
||||
>
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
class:transition-transform={!zp.panning}
|
||||
class:duration-150={!zp.panning}
|
||||
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
|
||||
>
|
||||
{#each files as file, i (file.UID)}
|
||||
<img
|
||||
src={thumbUrl(file.Hash, 'fit_2048')}
|
||||
alt={file.Name}
|
||||
draggable="false"
|
||||
decoding="async"
|
||||
class="absolute max-h-full max-w-full select-none object-contain {i === index
|
||||
? ''
|
||||
: 'invisible'}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flip hint -->
|
||||
<div class="px-4 py-2 text-center text-[11px] text-white/50">
|
||||
←/→ flip candidates (zoom is preserved) · scroll to zoom · Enter keeps the shown file
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,43 +1,33 @@
|
||||
<!--
|
||||
One cross-folder duplicate group rendered as a card. Lists every on-disk
|
||||
copy of the same byte-identical file. The user picks one to keep; the
|
||||
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
|
||||
One cross-folder duplicate group. Every copy is byte-identical (same
|
||||
sha1, same thumbnail) so the old N-identical-thumbnails grid told the
|
||||
user nothing — the actual decision is entirely about *which path* to
|
||||
keep. Redesigned as one thumbnail + a radio-style path list.
|
||||
|
||||
Differences from StackGroupCard (which operates on PhotoPrism Files in
|
||||
a single Photo stack):
|
||||
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
|
||||
index time). They're files on disk only.
|
||||
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
|
||||
can render every copy from the same hash even though only one Photo
|
||||
entry exists.
|
||||
- Resolution moves files (reversible) rather than deletes (irreversible).
|
||||
Resolution moves files (reversible, quarantine + undo) rather than
|
||||
deletes — logic lives in services/duplicateActions.svelte.ts.
|
||||
|
||||
Same keyboard contract as StackGroupCard: arrows pick the keeper,
|
||||
Enter commits.
|
||||
Keyboard (↑/↓/j/k bubble to DuplicatesView's group navigation):
|
||||
- ←/→ or 1–9 move the keeper pick.
|
||||
- Enter archives every other copy.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
archiveDuplicatePaths,
|
||||
type CrossFolderDuplicateGroup
|
||||
} from '$lib/services/photoprism';
|
||||
import { resolveCrossFolder } from '$lib/services/duplicateActions.svelte';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import type { CrossFolderDuplicateGroup } from '$lib/services/photoprism';
|
||||
import { Check, Clock } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
group: CrossFolderDuplicateGroup;
|
||||
/** First-card auto-focus, same pattern as StackGroupCard. */
|
||||
autoFocus?: boolean;
|
||||
focused?: boolean;
|
||||
onFocusRequest?: () => void;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
let { group, autoFocus = false }: Props = $props();
|
||||
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let keep = $state('');
|
||||
let busy = $state(false);
|
||||
let sectionEl: HTMLElement | undefined = $state();
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
let cols = $state(1);
|
||||
|
||||
// Seed `keep` from the indexed path when available; that's the safest
|
||||
// default because losing it would leave PhotoPrism with no copy. Fall
|
||||
@@ -48,38 +38,15 @@
|
||||
keep =
|
||||
group.indexedPath && validPaths.has(group.indexedPath)
|
||||
? group.indexedPath
|
||||
: group.files[0]?.path ?? '';
|
||||
: (group.files[0]?.path ?? '');
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// Column-count tracking — identical pattern to StackGroupCard.
|
||||
$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();
|
||||
});
|
||||
$effect(() => {
|
||||
void view.thumbnailSize;
|
||||
queueMicrotask(() => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
});
|
||||
if (focused && sectionEl) {
|
||||
sectionEl.focus({ preventScroll: true });
|
||||
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
function sizeLabel(bytes: number): string {
|
||||
@@ -93,6 +60,38 @@
|
||||
return segs.slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
/** Relative age label from mtime, e.g. "3mo older" — helps break ties
|
||||
* when neither copy is the indexed one. */
|
||||
function relAge(iso: string | undefined, newestMs: number): string {
|
||||
if (!iso) return '';
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return '';
|
||||
const diffDays = Math.round((newestMs - ms) / 86_400_000);
|
||||
if (diffDays <= 0) return 'newest';
|
||||
if (diffDays < 30) return `${diffDays}d older`;
|
||||
if (diffDays < 365) return `${Math.round(diffDays / 30)}mo older`;
|
||||
return `${Math.round(diffDays / 365)}y older`;
|
||||
}
|
||||
|
||||
const newestMs = $derived(
|
||||
Math.max(...group.files.map((f) => (f.modTime ? Date.parse(f.modTime) : 0)))
|
||||
);
|
||||
|
||||
/** Highlight the differing folder segment(s) so the eye jumps straight
|
||||
* to what's actually different between two long, mostly-shared paths. */
|
||||
function highlightDiff(path: string): { prefix: string; diff: string; suffix: string } {
|
||||
const common = group.files
|
||||
.map((f) => f.path)
|
||||
.reduce((acc, p) => {
|
||||
let i = 0;
|
||||
while (i < acc.length && i < p.length && acc[i] === p[i]) i++;
|
||||
return acc.slice(0, i);
|
||||
});
|
||||
// Back up to the last '/' so we don't split mid-segment.
|
||||
const cut = common.lastIndexOf('/') + 1;
|
||||
return { prefix: path.slice(0, cut), diff: path.slice(cut), suffix: '' };
|
||||
}
|
||||
|
||||
function moveKeep(delta: number) {
|
||||
const i = group.files.findIndex((f) => f.path === keep);
|
||||
if (i < 0) return;
|
||||
@@ -102,71 +101,40 @@
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (busy) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveKeep(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveKeep(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveKeep(-cols);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveKeep(cols);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void commit();
|
||||
return;
|
||||
case 'Escape':
|
||||
(e.target as HTMLElement)?.blur();
|
||||
return;
|
||||
default: {
|
||||
const n = Number.parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= group.files.length) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
keep = group.files[n - 1].path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (busy || group.files.length < 2) return;
|
||||
// Defensive guard: never archive the indexed copy. The user can
|
||||
// pick a different "keeper" but the archive list is computed AFTER
|
||||
// resolving that into "everything except the keeper". If they pick
|
||||
// a non-indexed copy as keeper, the indexed one gets archived —
|
||||
// PhotoPrism will lose its photo entry on the cleanup reindex.
|
||||
// That's a legitimate user choice (they wanted to move the
|
||||
// canonical copy), just call it out in the toast.
|
||||
const losers = group.files.filter((f) => f.path !== keep);
|
||||
if (losers.length === 0) return;
|
||||
const losingIndexed =
|
||||
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
`Archived ${result.moved.length}; ${result.errors.length} failed`,
|
||||
{
|
||||
description: result.errors[0].error
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
||||
{
|
||||
description: losingIndexed
|
||||
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
|
||||
: 'Files moved to .duplicates/ inside originals.'
|
||||
}
|
||||
);
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
const ok = await resolveCrossFolder(group, keep);
|
||||
if (ok) onResolved?.();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -179,87 +147,101 @@
|
||||
bind:this={sectionEl}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label={`Duplicate group · ${group.files.length} copies`}
|
||||
aria-label={`Duplicate group of ${group.files.length} copies — ←/→ pick which path to keep, Enter archives the rest`}
|
||||
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"
|
||||
onfocusin={() => onFocusRequest?.()}
|
||||
class="flex gap-3 rounded-md border bg-card/30 p-3 outline-none transition-colors
|
||||
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<!-- Single thumbnail — every copy is byte-identical, so N tiles of the
|
||||
same image told the user nothing. -->
|
||||
<div class="w-28 shrink-0">
|
||||
<div class="aspect-square w-full overflow-hidden rounded-md border border-border bg-secondary">
|
||||
<img
|
||||
src={thumbUrl(group.hash, 'tile_500')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-center text-[10px] font-mono text-muted-foreground/70">
|
||||
sha1 {group.hash.slice(0, 10)}…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
{group.files.length} copies · {sizeLabel(group.size)} each
|
||||
</div>
|
||||
<div class="truncate text-[10px] font-mono text-muted-foreground">
|
||||
sha1 {group.hash.slice(0, 16)}…
|
||||
</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="Move the unselected copies to .duplicates/ (reversible)"
|
||||
>
|
||||
Keep selected, archive 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.path)}
|
||||
{@const isKeep = file.path === keep}
|
||||
{@const isIndexed = file.path === group.indexedPath}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (keep = file.path)}
|
||||
class:scale-95={isKeep}
|
||||
class:ring-2={isKeep}
|
||||
class:ring-blue-500={isKeep}
|
||||
class:ring-offset-2={isKeep}
|
||||
class:ring-offset-background={isKeep}
|
||||
class:transition-[transform,box-shadow]={isKeep}
|
||||
class:duration-300={isKeep}
|
||||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
|
||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
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="Move the unselected copies to .duplicates/ (recoverable)"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(group.hash, 'tile_500')}
|
||||
alt={file.path}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if isKeep}
|
||||
<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"
|
||||
>
|
||||
Keep
|
||||
</span>
|
||||
{/if}
|
||||
{#if isIndexed}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
title="Currently in the library"
|
||||
>
|
||||
Indexed
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||
title={file.path}
|
||||
Keep selected path
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Enter</kbd
|
||||
>
|
||||
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
|
||||
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</header>
|
||||
|
||||
<!-- Radio-style path list — the actual decision surface. -->
|
||||
<div class="space-y-1">
|
||||
{#each group.files as file, i (file.path)}
|
||||
{@const isKeep = file.path === keep}
|
||||
{@const isIndexed = file.path === group.indexedPath}
|
||||
{@const parts = highlightDiff(file.path)}
|
||||
{@const age = relAge(file.modTime, newestMs)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (keep = file.path)}
|
||||
class="flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors
|
||||
{isKeep
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-transparent bg-secondary/50 hover:bg-secondary'}"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold
|
||||
{isKeep
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-muted-foreground/40 text-muted-foreground'}"
|
||||
>
|
||||
{isKeep ? '' : i + 1}
|
||||
{#if isKeep}<Check class="h-2.5 w-2.5" />{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
<span class="text-muted-foreground">{parts.prefix}</span><span
|
||||
class="font-semibold text-foreground"
|
||||
>{parts.diff}</span
|
||||
>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1.5 text-[10px]">
|
||||
{#if isIndexed}
|
||||
<span
|
||||
class="rounded bg-emerald-600 px-1.5 py-0.5 font-semibold text-white"
|
||||
title="Currently in the library — losing this moves the indexed copy"
|
||||
>
|
||||
Indexed
|
||||
</span>
|
||||
{/if}
|
||||
{#if age}
|
||||
<span class="flex items-center gap-0.5 text-muted-foreground" title={file.modTime}>
|
||||
<Clock class="h-2.5 w-2.5" />{age}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if group.files.some((f) => f.path === group.indexedPath && f.path !== keep)}
|
||||
<p class="text-[10px] text-amber-500">
|
||||
Keeping a non-indexed copy — the indexed one will be archived; the indexer picks up the
|
||||
survivor on its next pass.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<!--
|
||||
Duplicate-resolution page body. Two panels driven by the parent
|
||||
route's `activeTab` prop (URL-bound):
|
||||
Duplicate-resolution queue. Two panels driven by the parent route's
|
||||
`activeTab` prop (URL-bound):
|
||||
|
||||
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
|
||||
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
|
||||
`stack:true` and resolve via `setPrimary` + `deleteFile`.
|
||||
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
|
||||
`stack:true`, resolved via resolveStack() (setPrimary + quarantine).
|
||||
|
||||
2. Cross-folder — files PhotoPrism silently rejected at index time
|
||||
because they were byte-identical to an existing entry. PhotoPrism
|
||||
never adds those rows to its DB, so we scan the filesystem via the
|
||||
mule-sidecar. Resolution moves the unwanted copies into a
|
||||
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
|
||||
because they were byte-identical to an existing entry. Scanned via
|
||||
the sidecar's filesystem walk, resolved via resolveCrossFolder()
|
||||
(quarantine).
|
||||
|
||||
Both panels share one interaction model — a resolve-and-advance
|
||||
queue: ↑/↓ or j/k rove between groups (scrollIntoView), resolving a
|
||||
group removes it optimistically and auto-advances focus to whatever
|
||||
now occupies that slot, so the whole queue clears without touching
|
||||
the mouse. A sticky header tracks reclaimable bytes and a running
|
||||
"resolved this session" tally.
|
||||
|
||||
The cross-folder scan auto-fires when its tab is active — with size
|
||||
pre-filtering it stays fast (~250ms for 400 files in practice) and a
|
||||
long staleTime keeps tab bounces from re-running it. The button is
|
||||
kept for manual "rescan after I moved files" refreshes.
|
||||
|
||||
Tabs themselves render in the parent route's Toolbar so they line up
|
||||
visually with the `/tags` pill row.
|
||||
long staleTime keeps tab bounces from re-running it.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -28,11 +30,20 @@
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
dupSession,
|
||||
formatBytes,
|
||||
resolvedCrossHashes,
|
||||
resolvedStackUids
|
||||
} from '$lib/services/duplicateActions.svelte';
|
||||
import { userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import { nearBottom } from '$lib/actions/nearBottom';
|
||||
import { toggleShortcuts, view } from '$lib/stores/view.svelte';
|
||||
import { popAndRun } from '$lib/stores/undo.svelte';
|
||||
import StackGroupCard from './StackGroupCard.svelte';
|
||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
|
||||
import { AlertCircle, CheckCircle2, Copy, HardDrive } from 'lucide-svelte';
|
||||
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
|
||||
@@ -64,97 +75,229 @@
|
||||
$effect(() => {
|
||||
if (crossQuery.error) {
|
||||
toast.error(
|
||||
crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'Duplicates scan failed'
|
||||
crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates scan failed'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
|
||||
// Filter out groups resolved this session but not yet reflected by a
|
||||
// server refetch (PhotoPrism's cleanup reindex is async) — otherwise
|
||||
// a background refetch could resurrect a group the user just cleared.
|
||||
const liveStackGroups = $derived(groups.filter((g) => !resolvedStackUids.has(g.photo.UID)));
|
||||
const liveCrossGroups = $derived(
|
||||
(crossQuery.data?.groups ?? []).filter((g) => !resolvedCrossHashes.has(g.hash))
|
||||
);
|
||||
|
||||
const activeGroups = $derived(activeTab === 'stacks' ? liveStackGroups : liveCrossGroups);
|
||||
|
||||
// Reclaimable bytes across everything still in the queue.
|
||||
const reclaimableBytes = $derived(
|
||||
activeTab === 'stacks'
|
||||
? liveStackGroups.reduce((sum, g) => {
|
||||
const keeperSize = Math.max(...g.files.map((f) => f.Size ?? 0));
|
||||
const total = g.files.reduce((s, f) => s + (f.Size ?? 0), 0);
|
||||
return sum + (total - keeperSize);
|
||||
}, 0)
|
||||
: liveCrossGroups.reduce((sum, g) => sum + g.size * (g.files.length - 1), 0)
|
||||
);
|
||||
|
||||
// ── Roving focus + progressive rendering ───────────────────────────
|
||||
let focusedIndex = $state(0);
|
||||
let renderCount = $state(30);
|
||||
|
||||
// Reset when the tab or the underlying list identity changes size
|
||||
// class (e.g. switching tabs, or a fresh scan lands).
|
||||
$effect(() => {
|
||||
void activeTab;
|
||||
focusedIndex = 0;
|
||||
renderCount = 30;
|
||||
});
|
||||
|
||||
function clampFocus() {
|
||||
if (activeGroups.length === 0) return;
|
||||
focusedIndex = Math.min(focusedIndex, activeGroups.length - 1);
|
||||
}
|
||||
$effect(clampFocus);
|
||||
|
||||
function extend() {
|
||||
renderCount = Math.min(activeGroups.length, renderCount + 30);
|
||||
}
|
||||
|
||||
function moveFocus(delta: number) {
|
||||
if (activeGroups.length === 0) return;
|
||||
focusedIndex = Math.min(Math.max(0, focusedIndex + delta), activeGroups.length - 1);
|
||||
if (focusedIndex >= renderCount) renderCount = Math.min(activeGroups.length, focusedIndex + 10);
|
||||
}
|
||||
|
||||
async function onQueueKeydown(e: KeyboardEvent) {
|
||||
if (view.shortcutsOpen) {
|
||||
if (e.key === 'Escape' || e.key === '?') {
|
||||
e.preventDefault();
|
||||
toggleShortcuts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// gridKeyNav (which normally owns ⌘Z) isn't mounted on these tabs —
|
||||
// wire undo here so resolving a group is reversible without
|
||||
// switching to a cause tab first.
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||
e.preventDefault();
|
||||
const entry = await popAndRun();
|
||||
toast[entry ? 'success' : 'message'](entry ? `Undone: ${entry.label}` : 'Nothing to undo');
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
// Group cards call stopPropagation on the keys they own (arrows
|
||||
// L/R, digits, Enter, Space) — only j/k/ArrowUp/ArrowDown/? reach
|
||||
// here, which is exactly the group-navigation contract.
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
moveFocus(-1);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
moveFocus(1);
|
||||
return;
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
toggleShortcuts();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** A group resolved — hold focus at the same index (the next group
|
||||
* slides up into it) unless we were at the end. */
|
||||
function onGroupResolved() {
|
||||
if (focusedIndex >= activeGroups.length - 1) {
|
||||
focusedIndex = Math.max(0, activeGroups.length - 2);
|
||||
}
|
||||
}
|
||||
|
||||
const crossCount = $derived(liveCrossGroups.length);
|
||||
</script>
|
||||
|
||||
<!-- Stacks tab ----------------------------------------------------- -->
|
||||
{#if activeTab === 'stacks'}
|
||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||
{#if pending}
|
||||
<InlineLoader label="Loading stacks…" />
|
||||
{:else if error}
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Could not load stacks"
|
||||
description={error instanceof Error ? error.message : 'unknown error'}
|
||||
/>
|
||||
{:else if groups.length === 0}
|
||||
<EmptyState icon={Copy} title="No stacks">
|
||||
{#snippet descriptionSnippet()}
|
||||
<p>
|
||||
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||
the Duplicates tab.
|
||||
</p>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each groups as group, i (group.photo.UID)}
|
||||
<StackGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Sticky progress header — shared by both tabs -->
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background/95 px-6 py-2.5 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground">
|
||||
{activeGroups.length}
|
||||
{activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{#if reclaimableBytes > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<HardDrive class="h-3 w-3" />
|
||||
{formatBytes(reclaimableBytes)} reclaimable
|
||||
</span>
|
||||
{/if}
|
||||
{#if dupSession.resolved > 0}
|
||||
<span class="text-emerald-500">
|
||||
Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<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={crossQuery.isFetching}
|
||||
onclick={rescan}
|
||||
>
|
||||
{crossQuery.isFetching ? 'Scanning…' : 'Rescan filesystem'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||
<header class="flex items-baseline justify-between gap-3">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Byte-identical files the indexer dropped at index time. Found by scanning the
|
||||
originals tree directly.
|
||||
</p>
|
||||
<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={crossQuery.isFetching}
|
||||
onclick={rescan}
|
||||
>
|
||||
{#if crossQuery.isFetching}
|
||||
Scanning…
|
||||
{:else}
|
||||
Rescan filesystem
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if crossQuery.isFetching && !crossQuery.data}
|
||||
<InlineLoader label="Hashing files under originals…" />
|
||||
{:else if crossQuery.isError}
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Scan failed"
|
||||
description={crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'unknown error'}
|
||||
/>
|
||||
{:else if crossCount === 0}
|
||||
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||
{#snippet descriptionSnippet()}
|
||||
{#if crossQuery.data}
|
||||
<p class="text-[10px] text-muted-foreground/70">
|
||||
scanned in {crossQuery.data.scannedMs} ms
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onkeydown={onQueueKeydown}>
|
||||
<!-- Stacks tab ----------------------------------------------------- -->
|
||||
{#if activeTab === 'stacks'}
|
||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||
{#if pending}
|
||||
<InlineLoader label="Loading stacks…" />
|
||||
{:else if error}
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Could not load stacks"
|
||||
description={error instanceof Error ? error.message : 'unknown error'}
|
||||
/>
|
||||
{:else if liveStackGroups.length === 0}
|
||||
<EmptyState icon={Copy} title="No stacks">
|
||||
{#snippet descriptionSnippet()}
|
||||
<p>
|
||||
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||
the Duplicates tab.
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
||||
<CrossFolderGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)}
|
||||
<StackGroupCard
|
||||
{group}
|
||||
focused={i === focusedIndex}
|
||||
onFocusRequest={() => (focusedIndex = i)}
|
||||
onResolved={onGroupResolved}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if renderCount < liveStackGroups.length}
|
||||
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Byte-identical files the indexer dropped at index time. Found by scanning the originals
|
||||
tree directly.
|
||||
</p>
|
||||
|
||||
{#if crossQuery.isFetching && !crossQuery.data}
|
||||
<InlineLoader label="Hashing files under originals…" />
|
||||
{:else if crossQuery.isError}
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Scan failed"
|
||||
description={crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'unknown error'}
|
||||
/>
|
||||
{:else if crossCount === 0}
|
||||
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||
{#snippet descriptionSnippet()}
|
||||
{#if crossQuery.data}
|
||||
<p class="text-[10px] text-muted-foreground/70">
|
||||
scanned in {crossQuery.data.scannedMs} ms
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each liveCrossGroups.slice(0, renderCount) as group, i (group.hash)}
|
||||
<CrossFolderGroupCard
|
||||
{group}
|
||||
focused={i === focusedIndex}
|
||||
onFocusRequest={() => (focusedIndex = i)}
|
||||
onResolved={onGroupResolved}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if renderCount < liveCrossGroups.length}
|
||||
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,101 +1,90 @@
|
||||
<!--
|
||||
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).
|
||||
One duplicate stack rendered as a card. Each variant file is a tile;
|
||||
the selected one is the "keeper". Committing promotes the keeper to
|
||||
Primary and moves every other file into the sidecar's `.duplicates/`
|
||||
quarantine (recoverable, undoable via ⌘Z) — resolution logic lives in
|
||||
services/duplicateActions.svelte.ts.
|
||||
|
||||
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 (card scope — ↑/↓/j/k are NOT consumed here; they bubble to
|
||||
DuplicatesView's group navigation):
|
||||
- ←/→ move the keeper highlight; 1–9 jump straight to a file.
|
||||
- Space opens the fullscreen compare lightbox (zoom-preserving flips).
|
||||
- Enter resolves: keep selected, quarantine the rest.
|
||||
|
||||
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.
|
||||
The fact rows under each thumb highlight the best value per column
|
||||
(largest size, highest resolution) so the winning file is obvious at
|
||||
a glance; a file that wins everything gets a "Suggested" badge.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteFile, setPrimary } from '$lib/services/photoprism';
|
||||
import { resolveStack } from '$lib/services/duplicateActions.svelte';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import type { PpFile } from '$lib/types/photoprism';
|
||||
import CompareLightbox from './CompareLightbox.svelte';
|
||||
import { Maximize2 } from 'lucide-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;
|
||||
/** Roving focus — DuplicatesView owns which card is active. */
|
||||
focused?: boolean;
|
||||
/** Card was clicked/focused by pointer: tell the view to move its
|
||||
* roving index here. */
|
||||
onFocusRequest?: () => void;
|
||||
/** Resolve succeeded — view advances focus to the next group. */
|
||||
onResolved?: () => void;
|
||||
}
|
||||
let { group, autoFocus = false }: Props = $props();
|
||||
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let best = $state('');
|
||||
let busy = $state(false);
|
||||
let compareOpen = $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).
|
||||
// changes; the guard keeps user clicks intact across prop swaps.
|
||||
if (!best || !group.files.some((f) => f.UID === best)) {
|
||||
best = group.bestFileUid;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||
if (focused && sectionEl && !compareOpen) {
|
||||
sectionEl.focus({ preventScroll: true });
|
||||
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
// ── Comparison facts ────────────────────────────────────────────────
|
||||
const maxSize = $derived(Math.max(...group.files.map((f) => f.Size ?? 0)));
|
||||
const maxPixels = $derived(Math.max(...group.files.map((f) => pixels(f))));
|
||||
const sizesDiffer = $derived(new Set(group.files.map((f) => f.Size ?? 0)).size > 1);
|
||||
const pixelsDiffer = $derived(new Set(group.files.map((f) => pixels(f))).size > 1);
|
||||
/** UID of the file that wins on every differing axis, if unique. */
|
||||
const suggestedUid = $derived.by(() => {
|
||||
const winners = group.files.filter(
|
||||
(f) =>
|
||||
(!sizesDiffer || (f.Size ?? 0) === maxSize) &&
|
||||
(!pixelsDiffer || pixels(f) === maxPixels)
|
||||
);
|
||||
return winners.length === 1 && (sizesDiffer || pixelsDiffer) ? winners[0].UID : null;
|
||||
});
|
||||
|
||||
function pixels(f: PpFile): number {
|
||||
return (f.Width ?? 0) * (f.Height ?? 0);
|
||||
}
|
||||
|
||||
function typeBadge(f: PpFile): string {
|
||||
return (f.FileType ?? f.Name?.split('.').pop() ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
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 {
|
||||
function dims(f: PpFile): string {
|
||||
if (!f.Width || !f.Height) return '';
|
||||
return `${f.Width}×${f.Height}`;
|
||||
}
|
||||
@@ -114,87 +103,57 @@
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (busy) return;
|
||||
if (busy || compareOpen) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveBest(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveBest(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
moveBest(-cols);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveBest(cols);
|
||||
e.stopPropagation();
|
||||
compareOpen = true;
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void commit();
|
||||
return;
|
||||
case 'Escape':
|
||||
(e.target as HTMLElement)?.blur();
|
||||
return;
|
||||
default: {
|
||||
const n = Number.parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= group.files.length) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
best = group.files[n - 1].UID;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const ok = await resolveStack(group, best);
|
||||
if (ok) onResolved?.();
|
||||
} 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.
|
||||
keys + Enter, not standard reading order). `<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 -->
|
||||
@@ -202,10 +161,11 @@
|
||||
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`}
|
||||
aria-label={`Duplicate stack of ${group.files.length} files — ←/→ pick the keeper, Space compares, 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"
|
||||
onfocusin={() => onFocusRequest?.()}
|
||||
class="space-y-2 rounded-md border bg-card/30 p-3 outline-none transition-colors
|
||||
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
@@ -216,74 +176,115 @@
|
||||
{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
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (compareOpen = true)}
|
||||
title="Compare candidates fullscreen (zoom-preserving flips)"
|
||||
>
|
||||
</button>
|
||||
<Maximize2 class="h-3 w-3" /> Compare
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Space</kbd
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex 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; the rest move to the recoverable .duplicates/ quarantine"
|
||||
>
|
||||
Keep selected
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Enter</kbd
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</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)}
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));">
|
||||
{#each group.files as file, i (file.UID)}
|
||||
{@const isBest = file.UID === best}
|
||||
{@const sizeStr = sizeLabel(file.Size)}
|
||||
{@const bestSize = sizesDiffer && (file.Size ?? 0) === maxSize}
|
||||
{@const bestRes = pixelsDiffer && pixels(file) === maxPixels && pixels(file) > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (best = file.UID)}
|
||||
class:scale-95={isBest}
|
||||
ondblclick={() => {
|
||||
best = file.UID;
|
||||
compareOpen = true;
|
||||
}}
|
||||
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"
|
||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none transition-shadow focus:outline-none"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(file.Hash, 'tile_500')}
|
||||
alt={file.Name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
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
|
||||
Keep
|
||||
</span>
|
||||
{/if}
|
||||
{#if dims(file)}
|
||||
{:else if file.UID === suggestedUid}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||
class="absolute left-1.5 top-1.5 rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
title="Largest and highest-resolution file in this stack"
|
||||
>
|
||||
{dims(file)}
|
||||
Suggested
|
||||
</span>
|
||||
{/if}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/90"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||
class="flex flex-col gap-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 class="flex items-center gap-1.5">
|
||||
{#if typeBadge(file)}
|
||||
<span class="rounded bg-muted px-1 py-px font-medium">{typeBadge(file)}</span>
|
||||
{/if}
|
||||
{#if dims(file)}
|
||||
<span class={bestRes ? 'font-semibold text-emerald-500' : ''}>{dims(file)}</span>
|
||||
{/if}
|
||||
{#if sizeStr}
|
||||
<span class={bestSize ? 'font-semibold text-emerald-500' : ''}>{sizeStr}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if compareOpen}
|
||||
<CompareLightbox
|
||||
files={group.files}
|
||||
startUid={best}
|
||||
onPick={(uid) => {
|
||||
best = uid;
|
||||
compareOpen = false;
|
||||
sectionEl?.focus({ preventScroll: true });
|
||||
}}
|
||||
onClose={() => {
|
||||
compareOpen = false;
|
||||
sectionEl?.focus({ preventScroll: true });
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
{ keys: ['I'], desc: 'Toggle info sidebar' },
|
||||
{ keys: ['?'], desc: 'This overlay' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Stacks & Duplicates',
|
||||
rows: [
|
||||
{ keys: ['↑', '↓', 'j', 'k'], desc: 'Move between groups' },
|
||||
{ keys: ['←', '→'], desc: 'Pick which file/copy to keep' },
|
||||
{ keys: ['1', '…', '9'], desc: 'Jump straight to a file/copy' },
|
||||
{ keys: ['Space'], desc: 'Compare candidates fullscreen (stacks)' },
|
||||
{ keys: ['Enter'], desc: 'Resolve: keep selected, quarantine rest' },
|
||||
{ keys: ['⌘', 'Z'], desc: 'Undo — restores quarantined files' }
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function listDuplicateGroups(basePath?: string): Promise<DuplicateG
|
||||
|
||||
const photos = await listPhotos({
|
||||
q,
|
||||
count: 200,
|
||||
count: 500,
|
||||
merged: true,
|
||||
order: 'newest'
|
||||
});
|
||||
|
||||
230
web/src/lib/services/duplicateActions.svelte.ts
Normal file
230
web/src/lib/services/duplicateActions.svelte.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Resolve/undo logic for the Stacks & Duplicates review tabs.
|
||||
*
|
||||
* Both tabs share one loser fate: files move to the sidecar's
|
||||
* `.duplicates/<timestamp>/` quarantine (recoverable), never a hard
|
||||
* delete. Stacks additionally promote the keeper to Primary first so
|
||||
* the surviving Photo row stays coherent while PhotoPrism's async
|
||||
* cleanup reindex catches up.
|
||||
*
|
||||
* Optimistic model: the resolved group is removed from the TanStack
|
||||
* cache immediately (no refetch), and its identity is remembered in a
|
||||
* session-level `resolved*` set. The set matters because quarantined
|
||||
* stack files linger in PhotoPrism's DB until the async reindex
|
||||
* completes — a plain refetch inside that window would resurrect the
|
||||
* group. Undo reverses all three: restores the files via the sidecar,
|
||||
* re-inserts the group into the cache, and forgets the identity.
|
||||
*/
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import {
|
||||
archiveDuplicatePaths,
|
||||
restoreDuplicatePaths,
|
||||
setPrimary,
|
||||
type CrossFolderDuplicateGroup,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import { userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
|
||||
/** Stack photo UIDs / cross-folder hashes resolved this session. Views
|
||||
* filter refetched lists through these so groups don't resurrect while
|
||||
* PhotoPrism's cleanup reindex is still running. SvelteSet so the
|
||||
* filtering is reactive to undo. */
|
||||
export const resolvedStackUids = new SvelteSet<string>();
|
||||
export const resolvedCrossHashes = new SvelteSet<string>();
|
||||
|
||||
/** Running session tally for the progress header. */
|
||||
export const dupSession = $state({ resolved: 0, freedBytes: 0 });
|
||||
|
||||
function stacksKey(): (string | undefined)[] {
|
||||
return ['duplicates', userLibraryBase()];
|
||||
}
|
||||
function crossKey(): (string | undefined)[] {
|
||||
return ['duplicates-cross-folder', userLibraryBase()];
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
if (bytes > 0) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
return '0 KB';
|
||||
}
|
||||
|
||||
function bumpSession(freed: number, dir: 1 | -1): void {
|
||||
dupSession.resolved = Math.max(0, dupSession.resolved + dir);
|
||||
dupSession.freedBytes = Math.max(0, dupSession.freedBytes + dir * freed);
|
||||
}
|
||||
|
||||
/** Remove/re-insert a stack group in the cached list. */
|
||||
function patchStacksCache(mutate: (list: DuplicateGroup[]) => DuplicateGroup[]): void {
|
||||
queryClient.setQueryData<DuplicateGroup[]>(stacksKey(), (list) =>
|
||||
list ? mutate(list) : list
|
||||
);
|
||||
}
|
||||
|
||||
function patchCrossCache(
|
||||
mutate: (groups: CrossFolderDuplicateGroup[]) => CrossFolderDuplicateGroup[]
|
||||
): void {
|
||||
queryClient.setQueryData<CrossFolderScanResult>(crossKey(), (res) =>
|
||||
res ? { ...res, groups: mutate(res.groups) } : res
|
||||
);
|
||||
}
|
||||
|
||||
function insertAt<T>(list: T[], item: T, index: number): T[] {
|
||||
const i = Math.min(Math.max(0, index), list.length);
|
||||
return [...list.slice(0, i), item, ...list.slice(i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stack: promote `keeperUid` to Primary, quarantine every
|
||||
* other file's on-disk copy. Returns true on success (view advances
|
||||
* focus on true).
|
||||
*/
|
||||
export async function resolveStack(group: DuplicateGroup, keeperUid: string): Promise<boolean> {
|
||||
const uid = group.photo.UID;
|
||||
const losers = group.files.filter((f) => f.UID !== keeperUid);
|
||||
if (losers.length === 0) return false;
|
||||
const loserPaths = losers.map((f) => f.Name).filter((n): n is string => !!n);
|
||||
const freed = losers.reduce((s, f) => s + (f.Size ?? 0), 0);
|
||||
|
||||
// Optimistic removal + session bookkeeping.
|
||||
let removedIndex = 0;
|
||||
patchStacksCache((list) => {
|
||||
removedIndex = Math.max(0, list.findIndex((g) => g.photo.UID === uid));
|
||||
return list.filter((g) => g.photo.UID !== uid);
|
||||
});
|
||||
resolvedStackUids.add(uid);
|
||||
bumpSession(freed, 1);
|
||||
|
||||
const rollback = () => {
|
||||
resolvedStackUids.delete(uid);
|
||||
bumpSession(freed, -1);
|
||||
patchStacksCache((list) =>
|
||||
list.some((g) => g.photo.UID === uid) ? list : insertAt(list, group, removedIndex)
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
|
||||
if (keeperUid !== currentPrimary) {
|
||||
await setPrimary(uid, keeperUid);
|
||||
}
|
||||
const result = await archiveDuplicatePaths(loserPaths);
|
||||
if (result.moved.length === 0) {
|
||||
rollback();
|
||||
toast.error('Resolve failed', { description: result.errors[0]?.error });
|
||||
return false;
|
||||
}
|
||||
const undo = async () => {
|
||||
try {
|
||||
const res = await restoreDuplicatePaths(result.moved);
|
||||
if (res.errors.length > 0) {
|
||||
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
|
||||
description: res.errors[0].error
|
||||
});
|
||||
}
|
||||
// Restoring undid a real filesystem move even if some files
|
||||
// failed partway — reflect it in the list either way.
|
||||
rollback();
|
||||
} catch (err) {
|
||||
// Network/HTTP failure — the quarantine move is still intact
|
||||
// on disk, so don't resurrect the group in the UI; the files
|
||||
// remain safely recoverable under .duplicates/ by hand.
|
||||
toast.error(err instanceof Error ? err.message : 'Undo failed');
|
||||
}
|
||||
};
|
||||
pushUndo(`Resolved stack (${group.files.length} files)`, undo);
|
||||
if (result.errors.length > 0) {
|
||||
toast.warning(`Kept 1 · quarantined ${result.moved.length}, ${result.errors.length} failed`, {
|
||||
description: result.errors[0].error
|
||||
});
|
||||
} else {
|
||||
toast.success(`Kept 1 of ${group.files.length} · ${formatBytes(freed)} freed`, {
|
||||
action: { label: 'Undo', onClick: () => void undo() }
|
||||
});
|
||||
}
|
||||
// Files moved on disk; photo counts/thumbs may shift once the
|
||||
// cleanup reindex lands. Background-invalidate the timeline only.
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
return true;
|
||||
} catch (err) {
|
||||
rollback();
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a cross-folder group: quarantine every copy except
|
||||
* `keeperPath`. Returns true on success.
|
||||
*/
|
||||
export async function resolveCrossFolder(
|
||||
group: CrossFolderDuplicateGroup,
|
||||
keeperPath: string
|
||||
): Promise<boolean> {
|
||||
const losers = group.files.filter((f) => f.path !== keeperPath);
|
||||
if (losers.length === 0) return false;
|
||||
const freed = losers.reduce((s, f) => s + f.size, 0);
|
||||
const losingIndexed = !!group.indexedPath && losers.some((f) => f.path === group.indexedPath);
|
||||
|
||||
let removedIndex = 0;
|
||||
patchCrossCache((groups) => {
|
||||
removedIndex = Math.max(0, groups.findIndex((g) => g.hash === group.hash));
|
||||
return groups.filter((g) => g.hash !== group.hash);
|
||||
});
|
||||
resolvedCrossHashes.add(group.hash);
|
||||
bumpSession(freed, 1);
|
||||
|
||||
const rollback = () => {
|
||||
resolvedCrossHashes.delete(group.hash);
|
||||
bumpSession(freed, -1);
|
||||
patchCrossCache((groups) =>
|
||||
groups.some((g) => g.hash === group.hash) ? groups : insertAt(groups, group, removedIndex)
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
|
||||
if (result.moved.length === 0) {
|
||||
rollback();
|
||||
toast.error('Archive failed', { description: result.errors[0]?.error });
|
||||
return false;
|
||||
}
|
||||
const undo = async () => {
|
||||
try {
|
||||
const res = await restoreDuplicatePaths(result.moved);
|
||||
if (res.errors.length > 0) {
|
||||
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
|
||||
description: res.errors[0].error
|
||||
});
|
||||
}
|
||||
rollback();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Undo failed');
|
||||
}
|
||||
};
|
||||
pushUndo(`Archived ${result.moved.length} duplicate(s)`, undo);
|
||||
if (result.errors.length > 0) {
|
||||
toast.warning(`Archived ${result.moved.length}, ${result.errors.length} failed`, {
|
||||
description: result.errors[0].error
|
||||
});
|
||||
} else {
|
||||
toast.success(`Archived ${result.moved.length} · ${formatBytes(freed)} freed`, {
|
||||
description: losingIndexed
|
||||
? 'The previously-indexed copy was moved; the indexer drops it on the next pass.'
|
||||
: undefined,
|
||||
action: { label: 'Undo', onClick: () => void undo() }
|
||||
});
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
return true;
|
||||
} catch (err) {
|
||||
rollback();
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -949,6 +949,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
||||
export interface DupFileEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
/** RFC3339 mtime — the only per-copy signal besides path, since all
|
||||
* copies in a group are byte-identical. */
|
||||
modTime?: string;
|
||||
}
|
||||
|
||||
export interface CrossFolderDuplicateGroup {
|
||||
@@ -981,6 +984,21 @@ export async function archiveDuplicatePaths(
|
||||
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||
}
|
||||
|
||||
export interface RestoreDuplicatesResult {
|
||||
restored: { from: string; to: string }[];
|
||||
errors: { path: string; error: string }[];
|
||||
}
|
||||
|
||||
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
|
||||
* archive response verbatim and the sidecar renames each quarantined
|
||||
* file back to its original path. Powers undo for duplicate/stack
|
||||
* resolution. */
|
||||
export async function restoreDuplicatePaths(
|
||||
moves: { from: string; to: string }[]
|
||||
): Promise<RestoreDuplicatesResult> {
|
||||
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
|
||||
}
|
||||
|
||||
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
||||
// Lives on the sidecar because moving the underlying files is a filesystem
|
||||
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
|
||||
|
||||
Reference in New Issue
Block a user