3 Commits

Author SHA1 Message Date
H1K0 d7bfe6b596 feat(frontend): enlarge cluster images in a preview lightbox
deploy / deploy (push) Successful in 36s
The duplicate cluster view only showed 96px thumbnails, too small to tell
near-duplicates apart. Add a zoom button per tile that opens a lightbox
showing the full preview — the same image the single-file viewer uses — and
pages across the cluster with arrows/Esc. The zoom button stops propagation
so it doesn't also reassign the cluster's "keep" selection.
2026-06-22 22:13:38 +03:00
H1K0 e5a731eb29 perf(frontend): lazy-load thumbnails via IntersectionObserver
Thumb and FileCard fetched their auth-gated thumbnail on mount, so a view
that rendered many tiles at once — notably a duplicate cluster, which can
hold hundreds of files — fired thousands of requests immediately and hung
the tab. Gate each fetch behind an IntersectionObserver that loads only when
the tile nears the viewport (200px rootMargin), then stops observing.
2026-06-22 22:10:12 +03:00
H1K0 6fe6e4cd55 chore(project): lower duplicate hash threshold to 4 in example env
The distance histogram over a real ~65k-pair table is flat-to-rising from
distance 1 and nearly doubles at 10 — the 8-10 band is dominated by
coincidental matches, not duplicates. Default the example to 4 (the valley
minimum) to keep the genuine-duplicate signal and cut the pairs to cluster.
2026-06-22 22:09:04 +03:00
5 changed files with 391 additions and 7 deletions
+6 -3
View File
@@ -131,9 +131,12 @@ IMPORT_PATH=/data/import
# Maximum perceptual-hash distance (Hamming, out of 64 bits) for two files to be
# treated as duplicate candidates. Lower = stricter (fewer, more confident
# matches); higher = looser (catches more re-encodes/resizes but risks false
# positives). Used only by the dedup tool's pairs rebuild — see the dedup CLI /
# `docker compose run --rm dedup`. Default 10.
DUPLICATE_HASH_THRESHOLD=10
# positives). On real libraries the distance histogram climbs steeply in the 810
# band — coincidental "vaguely similar" pairs, not duplicates — so 4 keeps the
# genuine-duplicate signal without that noise (and far fewer pairs to cluster).
# Used only by the dedup tool's pairs rebuild — see the dedup CLI /
# `docker compose run --rm dedup`. Code default is 10.
DUPLICATE_HASH_THRESHOLD=4
# ---------------------------------------------------------------------------
# Static SPA
@@ -30,8 +30,32 @@
let imgSrc = $state<string | null>(null);
let failed = $state(false);
// Gate the fetch on visibility so a long grid doesn't fire every thumbnail
// request on mount; the tile loads once it scrolls near the viewport.
let visible = $state(false);
// Svelte action: flips `visible` true the first time the card nears the
// viewport, then stops observing — the blob is kept once loaded.
function lazyload(node: HTMLElement) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
visible = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
$effect(() => {
if (!visible) return;
const token = get(authStore).accessToken;
let objectUrl: string | null = null;
let cancelled = false;
@@ -112,6 +136,7 @@
class:loaded={!!imgSrc}
class:selected
class:focused
use:lazyload
data-file-index={index}
onpointerdown={onPointerDown}
onpointermove={onPointerMoveInternal}
@@ -0,0 +1,252 @@
<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>
+29 -3
View File
@@ -14,17 +14,43 @@
let imgSrc = $state<string | null>(null);
let failed = $state(false);
// Gate the fetch on visibility. A duplicate cluster can hold hundreds of files,
// and firing every thumbnail request on mount buries the server in a request
// storm (10k+ in-flight is easy). We only load once the tile nears the viewport.
let visible = $state(false);
// Svelte action: flips `visible` true the first time the tile nears the
// viewport, then stops observing — the blob is kept once loaded.
function lazyload(node: HTMLElement) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
visible = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
// Thumbnails are auth-gated, so fetch with the bearer token and render the blob
// (mirrors FileCard's loader). Re-runs whenever the id changes.
// (mirrors FileCard's loader). Runs once visible; re-runs whenever the id changes.
$effect(() => {
if (!visible) return;
const token = get(authStore).accessToken;
const currentId = id; // track id so a reused node refetches on change
let objectUrl: string | null = null;
let cancelled = false;
imgSrc = null;
failed = false;
fetch(`/api/v1/files/${id}/thumbnail`, {
fetch(`/api/v1/files/${currentId}/thumbnail`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
.then((res) => (res.ok ? res.blob() : null))
@@ -47,7 +73,7 @@
});
</script>
<div class="thumb" style="width:{size}px;height:{size}px">
<div class="thumb" use:lazyload style="width:{size}px;height:{size}px">
{#if imgSrc}
<img src={imgSrc} {alt} draggable="false" />
{:else if failed}
@@ -4,6 +4,7 @@
import { getDuplicates, dismissDuplicate, type DuplicateCluster } 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 type { File } from '$lib/api/types';
const LIMIT = 20;
@@ -22,6 +23,10 @@
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);
$effect(() => {
if (!initialLoaded && !loading) void load();
});
@@ -61,6 +66,10 @@
keepers = { ...keepers, [clusterKey(c)]: id };
}
function openLightbox(c: DuplicateCluster, startId: string) {
lightbox = { files: c.files, startId };
}
function openMerge(c: DuplicateCluster, other: File) {
const keep = c.files.find((f) => f.id === keeperId(c));
if (!keep) return;
@@ -160,7 +169,34 @@
onclick={() => setKeeper(c, f.id)}
title="Click to keep this one"
>
<div class="thumbwrap">
<Thumb id={f.id} size={96} alt={f.original_name ?? ''} />
<button
class="zoom"
onclick={(e) => {
e.stopPropagation();
openLightbox(c, f.id);
}}
aria-label="Enlarge preview"
title="Enlarge preview"
>
<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" />
<path
d="M10 10l3.5 3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M6.5 4.5v4M4.5 6.5h4"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
</div>
{#if f.id === keep}<span class="kbadge">Keep</span>{/if}
<span class="fname" title={f.original_name ?? ''}>{f.original_name ?? '—'}</span>
<span class="fmeta">{f.mime_type} · {f.tags?.length ?? 0} tags</span>
@@ -191,6 +227,14 @@
</main>
</div>
{#if lightbox}
<PreviewLightbox
files={lightbox.files}
startId={lightbox.startId}
onClose={() => (lightbox = null)}
/>
{/if}
{#if mergeKeep && mergeDiscard}
<DuplicateMergeDialog
keep={mergeKeep}
@@ -300,6 +344,40 @@
border-color: var(--color-accent);
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.thumbwrap {
position: relative;
line-height: 0;
}
.zoom {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
/* Always show the zoom on touch (no hover); reveal on hover for pointers. */
.thumbwrap:hover .zoom,
.zoom:focus-visible {
opacity: 1;
}
@media (hover: none) {
.zoom {
opacity: 1;
}
}
.zoom:hover {
background-color: rgba(0, 0, 0, 0.85);
}
.kbadge {
font-size: 0.68rem;
font-weight: 600;