2 Commits

Author SHA1 Message Date
H1K0 281358ff04 feat(frontend): open cluster files in the full viewer for comparison
deploy / deploy (push) Successful in 18s
The lightbox only showed the preview image, but dedup decisions need the
metadata — date, tags, EXIF — to choose a survivor. Replace it with the same
FileViewer the files page uses, mounted as a full-screen overlay with prev/
next wired across the cluster's files. A review toggle made in the viewer is
mirrored back into the cluster list. Drops the now-superseded PreviewLightbox.
2026-06-22 22:26:08 +03:00
H1K0 d8cccbb9e0 fix(frontend): resolve duplicate actions in place without a full reload
Every action button (Not a dup, Delete, Merge) called reload(), which wiped
the whole cluster list and refetched from offset 0 — re-running the costly
server-side clustering and jumping the scroll to the top on each tap. Give
each cluster a stable local key and edit it in place: drop the resolved file
from its cluster, removing the cluster when fewer than two files remain. The
server view is live, so subsequent pages reconcile. A separate monotonic page
cursor keeps "Load more" from repeating clusters after local removals.
2026-06-22 22:20:39 +03:00
2 changed files with 131 additions and 298 deletions
@@ -1,252 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import { untrack } from 'svelte';
import { authStore } from '$lib/stores/auth';
import type { File } from '$lib/api/types';
interface Props {
/** Files to page through (e.g. a duplicate cluster). */
files: File[];
/** Id to open first. */
startId: string;
onClose: () => void;
}
let { files, startId, onClose }: Props = $props();
// Resolve the starting index once. The lightbox is remounted on each open, so
// the props are effectively init-only — untrack acknowledges that read.
let index = $state(untrack(() => Math.max(0, files.findIndex((f) => f.id === startId))));
let src = $state<string | null>(null);
let failed = $state(false);
let current = $derived(files[index]);
let hasPrev = $derived(index > 0);
let hasNext = $derived(index < files.length - 1);
// Load the full preview — the same image the single-file viewer shows, so the
// user can actually tell duplicates apart instead of squinting at thumbnails.
// Auth-gated, rendered from a blob; re-runs when the index changes.
$effect(() => {
const f = files[index];
if (!f) return;
const token = get(authStore).accessToken;
let objectUrl: string | null = null;
let cancelled = false;
src = null;
failed = false;
fetch(`/api/v1/files/${f.id}/preview`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (cancelled || !blob) {
if (!cancelled) failed = true;
return;
}
objectUrl = URL.createObjectURL(blob);
src = objectUrl;
})
.catch(() => {
if (!cancelled) failed = true;
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
});
function prev() {
if (hasPrev) index -= 1;
}
function next() {
if (hasNext) index += 1;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Close only when the backdrop itself is clicked (not the image or controls);
Escape and the × button close from anywhere. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-label="Enlarged preview"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<button class="close" onclick={onClose} aria-label="Close">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<path
d="M6 6l10 10M16 6L6 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div class="stage">
{#if src}
<img class="img" {src} alt={current?.original_name ?? ''} />
{:else if failed}
<div class="ph failed">Failed to load preview</div>
{:else}
<div class="ph loading">Loading…</div>
{/if}
{#if hasPrev}
<button class="nav prev" onclick={prev} aria-label="Previous">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M12 4L6 10L12 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
{#if hasNext}
<button class="nav next" onclick={next} aria-label="Next">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M8 4L14 10L8 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
<div class="caption">
<span class="name" title={current?.original_name ?? ''}>{current?.original_name ?? '—'}</span>
<span class="pos">{index + 1} / {files.length}</span>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
background-color: rgba(0, 0, 0, 0.88);
}
.close {
position: absolute;
top: 12px;
right: 12px;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
cursor: pointer;
}
.close:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 0;
flex: 1;
}
.img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
border-radius: 6px;
}
.ph {
display: flex;
align-items: center;
justify-content: center;
min-width: 220px;
min-height: 220px;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.ph.failed {
color: var(--color-danger);
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.nav:hover {
background-color: rgba(0, 0, 0, 0.85);
}
.nav.prev {
left: 8px;
}
.nav.next {
right: 8px;
}
.caption {
display: flex;
align-items: center;
gap: 10px;
max-width: 100%;
color: var(--color-text-primary);
font-size: 0.85rem;
flex-shrink: 0;
}
.caption .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.caption .pos {
color: var(--color-text-muted);
flex-shrink: 0;
}
</style>
+130 -45
View File
@@ -1,41 +1,51 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { getDuplicates, dismissDuplicate, type DuplicateCluster } from '$lib/api/duplicates';
import { getDuplicates, dismissDuplicate } from '$lib/api/duplicates';
import Thumb from '$lib/components/file/Thumb.svelte';
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
import PreviewLightbox from '$lib/components/file/PreviewLightbox.svelte';
import FileViewer from '$lib/components/file/FileViewer.svelte';
import type { File } from '$lib/api/types';
const LIMIT = 20;
let clusters = $state<DuplicateCluster[]>([]);
// A cluster carries a stable local key so resolving one pair (delete / dismiss /
// merge) can edit it in place — no full reload, no scroll jump, no lost "keep".
interface Cluster {
key: number;
files: File[];
}
let nextKey = 0;
let clusters = $state<Cluster[]>([]);
let total = $state(0);
// Server group cursor; advances monotonically per page so local removals don't
// shift the offset and make "Load more" repeat or skip clusters.
let offset = $state(0);
let loading = $state(false);
let initialLoaded = $state(false);
let error = $state('');
let busyKey = $state(''); // cluster currently performing an action
let busyId = $state<number | null>(null); // cluster currently performing an action
// Which file is the survivor for a given cluster (keyed by its file-id set).
let keepers = $state<Record<string, string>>({});
// Which file is the survivor for a given cluster (keyed by its stable key).
let keepers = $state<Record<number, string>>({});
// Merge dialog state.
// Merge dialog state — mergeId pins the cluster so onMerged edits the right one.
let mergeId = $state<number | null>(null);
let mergeKeep = $state<File | null>(null);
let mergeDiscard = $state<File | null>(null);
// Enlarged-preview lightbox: thumbnails are too small to tell near-duplicates
// apart, so a zoom opens the full preview and pages across the cluster.
let lightbox = $state<{ files: File[]; startId: string } | null>(null);
// Full viewer (same as the files page): thumbnails are too small to compare,
// and dedup decisions need date / tags / EXIF, so the zoom opens the real
// viewer and pages across the cluster's files.
let viewer = $state<{ key: number; files: File[]; id: string } | null>(null);
$effect(() => {
if (!initialLoaded && !loading) void load();
});
function clusterKey(c: DuplicateCluster): string {
return c.files.map((f) => f.id).join(',');
}
function keeperId(c: DuplicateCluster): string {
return keepers[clusterKey(c)] ?? c.files[0]?.id ?? '';
function keeperId(c: Cluster): string {
return keepers[c.key] ?? c.files[0]?.id ?? '';
}
async function load() {
@@ -43,9 +53,13 @@
loading = true;
error = '';
try {
const res = await getDuplicates(LIMIT, clusters.length);
clusters = [...clusters, ...(res.items ?? [])];
total = res.total ?? clusters.length;
const res = await getDuplicates(LIMIT, offset);
const incoming = (res.items ?? []).map((c) => ({ key: nextKey++, files: c.files }));
total = res.total ?? total;
// The server paginates by group index and may drop groups that fell below
// two live files, so advance by the page size (clamped), not items returned.
offset = Math.min(offset + LIMIT, total);
clusters = [...clusters, ...incoming];
} catch {
error = 'Failed to load duplicates';
} finally {
@@ -58,58 +72,114 @@
clusters = [];
keepers = {};
total = 0;
offset = 0;
initialLoaded = false;
await load();
}
function setKeeper(c: DuplicateCluster, id: string) {
keepers = { ...keepers, [clusterKey(c)]: id };
function setKeeper(c: Cluster, id: string) {
keepers = { ...keepers, [c.key]: id };
}
function openLightbox(c: DuplicateCluster, startId: string) {
lightbox = { files: c.files, startId };
// Drop one file from a cluster after it's resolved, in place. With fewer than
// two files there's nothing left to compare, so the cluster — and its slot in
// the total — goes away. The server's view is live, so a later page reconciles.
function removeFile(key: number, fileId: string) {
const target = clusters.find((c) => c.key === key);
if (!target) return;
const remaining = target.files.filter((f) => f.id !== fileId);
const dropCluster = remaining.length < 2;
if (dropCluster) {
clusters = clusters.filter((c) => c.key !== key);
total = Math.max(0, total - 1);
} else {
clusters = clusters.map((c) => (c.key === key ? { ...c, files: remaining } : c));
}
// Forget a stale survivor pick when its cluster is gone or the pick was removed.
if (dropCluster || keepers[key] === fileId) {
const next = { ...keepers };
delete next[key];
keepers = next;
}
}
function openMerge(c: DuplicateCluster, other: File) {
function openViewer(c: Cluster, id: string) {
viewer = { key: c.key, files: c.files, id };
}
// Prev/next within the cluster currently open in the viewer.
let viewerPrevId = $derived.by(() => {
const v = viewer;
if (!v) return null;
const i = v.files.findIndex((f) => f.id === v.id);
return i > 0 ? (v.files[i - 1]?.id ?? null) : null;
});
let viewerNextId = $derived.by(() => {
const v = viewer;
if (!v) return null;
const i = v.files.findIndex((f) => f.id === v.id);
return i >= 0 && i < v.files.length - 1 ? (v.files[i + 1]?.id ?? null) : null;
});
function viewerNavigate(id: string) {
if (viewer) viewer = { ...viewer, id };
}
// Mirror a review toggle made inside the viewer back into the cluster list and
// the viewer's own navigation snapshot so both stay consistent.
function onViewerReviewChange(id: string, needsReview: boolean) {
const apply = (f: File) => (f.id === id ? { ...f, needs_review: needsReview } : f);
clusters = clusters.map((c) =>
c.key === viewer?.key ? { ...c, files: c.files.map(apply) } : c
);
if (viewer) viewer = { ...viewer, files: viewer.files.map(apply) };
}
function openMerge(c: Cluster, other: File) {
const keep = c.files.find((f) => f.id === keeperId(c));
if (!keep) return;
mergeId = c.key;
mergeKeep = keep;
mergeDiscard = other;
}
async function deleteFile(c: DuplicateCluster, id: string) {
if (busyKey) return;
busyKey = clusterKey(c);
async function deleteFile(c: Cluster, id: string) {
if (busyId !== null) return;
busyId = c.key;
try {
await api.post('/files/bulk/delete', { file_ids: [id] });
await reload();
removeFile(c.key, id);
} catch {
error = 'Failed to delete file';
} finally {
busyKey = '';
busyId = null;
}
}
async function notDuplicate(c: DuplicateCluster, other: File) {
if (busyKey) return;
busyKey = clusterKey(c);
async function notDuplicate(c: Cluster, other: File) {
if (busyId !== null) return;
busyId = c.key;
try {
await dismissDuplicate(keeperId(c), other.id);
await reload();
removeFile(c.key, other.id);
} catch {
error = 'Failed to dismiss pair';
} finally {
busyKey = '';
busyId = null;
}
}
function onMerged() {
const key = mergeId;
const discardId = mergeDiscard?.id;
mergeId = null;
mergeKeep = null;
mergeDiscard = null;
void reload();
if (key !== null && discardId) removeFile(key, discardId);
}
let hasMore = $derived(clusters.length < total);
let hasMore = $derived(offset < total);
</script>
<svelte:head>
@@ -155,9 +225,9 @@
</div>
{/if}
{#each clusters as c (clusterKey(c))}
{#each clusters as c (c.key)}
{@const keep = keeperId(c)}
<section class="cluster" class:busy={busyKey === clusterKey(c)}>
<section class="cluster" class:busy={busyId === c.key}>
<div class="files">
{#each c.files as f (f.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -175,10 +245,10 @@
class="zoom"
onclick={(e) => {
e.stopPropagation();
openLightbox(c, f.id);
openViewer(c, f.id);
}}
aria-label="Enlarge preview"
title="Enlarge preview"
aria-label="Open in viewer"
title="Open in viewer"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" stroke-width="1.5" />
@@ -227,12 +297,17 @@
</main>
</div>
{#if lightbox}
<PreviewLightbox
files={lightbox.files}
startId={lightbox.startId}
onClose={() => (lightbox = null)}
{#if viewer}
<div class="viewer-overlay">
<FileViewer
fileId={viewer.id}
prevId={viewerPrevId}
nextId={viewerNextId}
onNavigate={viewerNavigate}
onClose={() => (viewer = null)}
onReviewChange={onViewerReviewChange}
/>
</div>
{/if}
{#if mergeKeep && mergeDiscard}
@@ -452,4 +527,14 @@
color: var(--color-text-muted);
font-size: 0.9rem;
}
/* Full-screen overlay for the file viewer, mirroring the files page. */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: var(--color-bg-primary);
overflow-y: auto;
overscroll-behavior: contain;
}
</style>