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.
This commit is contained in:
2026-06-22 22:20:39 +03:00
parent d7bfe6b596
commit d8cccbb9e0
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api/client'; 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 Thumb from '$lib/components/file/Thumb.svelte';
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte'; import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
import PreviewLightbox from '$lib/components/file/PreviewLightbox.svelte'; import PreviewLightbox from '$lib/components/file/PreviewLightbox.svelte';
@@ -9,17 +9,29 @@
const LIMIT = 20; 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); 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 loading = $state(false);
let initialLoaded = $state(false); let initialLoaded = $state(false);
let error = $state(''); 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). // Which file is the survivor for a given cluster (keyed by its stable key).
let keepers = $state<Record<string, string>>({}); 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 mergeKeep = $state<File | null>(null);
let mergeDiscard = $state<File | null>(null); let mergeDiscard = $state<File | null>(null);
@@ -31,11 +43,8 @@
if (!initialLoaded && !loading) void load(); if (!initialLoaded && !loading) void load();
}); });
function clusterKey(c: DuplicateCluster): string { function keeperId(c: Cluster): string {
return c.files.map((f) => f.id).join(','); return keepers[c.key] ?? c.files[0]?.id ?? '';
}
function keeperId(c: DuplicateCluster): string {
return keepers[clusterKey(c)] ?? c.files[0]?.id ?? '';
} }
async function load() { async function load() {
@@ -43,9 +52,13 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
const res = await getDuplicates(LIMIT, clusters.length); const res = await getDuplicates(LIMIT, offset);
clusters = [...clusters, ...(res.items ?? [])]; const incoming = (res.items ?? []).map((c) => ({ key: nextKey++, files: c.files }));
total = res.total ?? clusters.length; 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 { } catch {
error = 'Failed to load duplicates'; error = 'Failed to load duplicates';
} finally { } finally {
@@ -58,58 +71,86 @@
clusters = []; clusters = [];
keepers = {}; keepers = {};
total = 0; total = 0;
offset = 0;
initialLoaded = false; initialLoaded = false;
await load(); await load();
} }
function setKeeper(c: DuplicateCluster, id: string) { function setKeeper(c: Cluster, id: string) {
keepers = { ...keepers, [clusterKey(c)]: id }; keepers = { ...keepers, [c.key]: id };
} }
function openLightbox(c: DuplicateCluster, startId: string) { // 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 openLightbox(c: Cluster, startId: string) {
lightbox = { files: c.files, startId }; lightbox = { files: c.files, startId };
} }
function openMerge(c: DuplicateCluster, other: File) { function openMerge(c: Cluster, other: File) {
const keep = c.files.find((f) => f.id === keeperId(c)); const keep = c.files.find((f) => f.id === keeperId(c));
if (!keep) return; if (!keep) return;
mergeId = c.key;
mergeKeep = keep; mergeKeep = keep;
mergeDiscard = other; mergeDiscard = other;
} }
async function deleteFile(c: DuplicateCluster, id: string) { async function deleteFile(c: Cluster, id: string) {
if (busyKey) return; if (busyId !== null) return;
busyKey = clusterKey(c); busyId = c.key;
try { try {
await api.post('/files/bulk/delete', { file_ids: [id] }); await api.post('/files/bulk/delete', { file_ids: [id] });
await reload(); removeFile(c.key, id);
} catch { } catch {
error = 'Failed to delete file'; error = 'Failed to delete file';
} finally { } finally {
busyKey = ''; busyId = null;
} }
} }
async function notDuplicate(c: DuplicateCluster, other: File) { async function notDuplicate(c: Cluster, other: File) {
if (busyKey) return; if (busyId !== null) return;
busyKey = clusterKey(c); busyId = c.key;
try { try {
await dismissDuplicate(keeperId(c), other.id); await dismissDuplicate(keeperId(c), other.id);
await reload(); removeFile(c.key, other.id);
} catch { } catch {
error = 'Failed to dismiss pair'; error = 'Failed to dismiss pair';
} finally { } finally {
busyKey = ''; busyId = null;
} }
} }
function onMerged() { function onMerged() {
const key = mergeId;
const discardId = mergeDiscard?.id;
mergeId = null;
mergeKeep = null; mergeKeep = null;
mergeDiscard = null; mergeDiscard = null;
void reload(); if (key !== null && discardId) removeFile(key, discardId);
} }
let hasMore = $derived(clusters.length < total); let hasMore = $derived(offset < total);
</script> </script>
<svelte:head> <svelte:head>
@@ -155,9 +196,9 @@
</div> </div>
{/if} {/if}
{#each clusters as c (clusterKey(c))} {#each clusters as c (c.key)}
{@const keep = keeperId(c)} {@const keep = keeperId(c)}
<section class="cluster" class:busy={busyKey === clusterKey(c)}> <section class="cluster" class:busy={busyId === c.key}>
<div class="files"> <div class="files">
{#each c.files as f (f.id)} {#each c.files as f (f.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->