feat: paginate duplicates view — 20 per page with Load more

Server returns page/limit/pages metadata; client fetches 20 groups at
a time and appends on "Load more". Shows remaining count in the button.
Dismiss and keep-best remove from the local list without a refetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-19 15:54:31 -04:00
1 parent 4f7dd3b49b
commit 64cca095ea
3 files changed
+91 -39

No files matched your search

+2 -2
View File
@@ -172,8 +172,8 @@ export function bulkAddToCollection(ids, collectionId) {
}
// Duplicates
export function fetchDuplicates() {
return api('/api/duplicates');
export function fetchDuplicates(page = 1, limit = 20) {
return api(`/api/duplicates?page=${page}&limit=${limit}`);
}
export function scanDuplicates() {
+77 -34
View File
@@ -2,44 +2,60 @@ import { useState, useEffect } from 'react';
import { fetchDuplicates, scanDuplicates, dismissGroup, keepBestDuplicate } from '../api/photos.js';
import DuplicateGroup from './DuplicateGroup.jsx';
const PAGE_SIZE = 20;
export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
const [groups, setGroups] = useState(null);
const [groups, setGroups] = useState([]);
const [total, setTotal] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState(null);
const [error, setError] = useState(null);
useEffect(() => { load(); }, []);
async function load() {
async function loadPage(p, replace = false) {
try {
const data = await fetchDuplicates();
setGroups(data.groups);
const data = await fetchDuplicates(p, PAGE_SIZE);
setTotal(data.total);
setPage(p);
setHasMore(p < data.pages);
setGroups(prev => replace ? data.groups : [...prev, ...data.groups]);
onGroupCountChange?.(data.total);
} catch (e) {
setError(e.message);
}
}
async function handleLoadMore() {
setLoadingMore(true);
await loadPage(page + 1, false);
setLoadingMore(false);
}
async function handleScan() {
setScanning(true);
setError(null);
try {
const result = await scanDuplicates();
setScanResult(result);
await load();
setLoading(true);
await loadPage(1, true);
} catch (e) {
setError(e.message);
} finally {
setScanning(false);
setLoading(false);
}
setScanning(false);
}
function removeGroup(photoIds) {
setGroups(prev => {
const next = prev.filter(g => {
const s = new Set(g.map(p => p.id));
return !photoIds.every(id => s.has(id));
});
onGroupCountChange?.(next.length);
function removeGroupLocal(photoIds) {
const ids = new Set(photoIds);
setGroups(prev => prev.filter(g => !photoIds.every(id => new Set(g.map(p => p.id)).has(id))));
setTotal(prev => {
const next = (prev ?? 1) - 1;
onGroupCountChange?.(next);
return next;
});
}
@@ -47,19 +63,26 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
async function handleDismiss(photoIds) {
try {
await dismissGroup(photoIds);
removeGroup(photoIds);
} catch {}
removeGroupLocal(photoIds);
} catch (e) {
setError(e.message);
}
}
async function handleKeepBest(photoIds) {
try {
await keepBestDuplicate(photoIds);
removeGroup(photoIds);
removeGroupLocal(photoIds);
} catch (e) {
setError(e.message);
}
}
// Initial load
useEffect(() => {
loadPage(1, true).finally(() => setLoading(false));
}, []);
return (
<div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}>
{/* Toolbar */}
@@ -92,32 +115,52 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
<p style={{ color: '#f87171', fontSize: 13, marginBottom: 16 }}>{error}</p>
)}
{groups === null && (
{loading && (
<p style={{ color: '#777', fontSize: 13 }}>Loading…</p>
)}
{groups !== null && groups.length === 0 && (
{!loading && total === 0 && (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#777' }}>
<p style={{ fontSize: 15, marginBottom: 8 }}>No similar photos to review.</p>
<p style={{ fontSize: 13 }}>Click "Scan library" to compare perceptual hashes across your library.</p>
</div>
)}
{groups !== null && groups.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss
{!loading && total > 0 && (
<>
<p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
{total} group{total !== 1 ? 's' : ''} — showing {groups.length}
{' · '}click a photo to inspect it
</p>
{groups.map((group, i) => (
<DuplicateGroup
key={i}
photos={group}
onDismiss={() => handleDismiss(group.map(p => p.id))}
onKeepBest={() => handleKeepBest(group.map(p => p.id))}
onOpen={(photo) => onOpenPhoto(photo, group)}
/>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{groups.map((group, i) => (
<DuplicateGroup
key={`${group[0]?.id}-${i}`}
photos={group}
onDismiss={() => handleDismiss(group.map(p => p.id))}
onKeepBest={() => handleKeepBest(group.map(p => p.id))}
onOpen={(photo) => onOpenPhoto(photo, group)}
/>
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<button
onClick={handleLoadMore}
disabled={loadingMore}
style={{
background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 6,
color: loadingMore ? '#444' : '#aaa', cursor: loadingMore ? 'default' : 'pointer',
padding: '8px 28px', fontSize: 13,
}}
>
{loadingMore ? 'Loading…' : `Load more (${total - groups.length} remaining)`}
</button>
</div>
)}
</>
)}
</div>
);
+12 -3
View File
@@ -568,9 +568,17 @@ app.post('/api/dedupe/scan', async (_req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/duplicates', (_req, res) => {
app.get('/api/duplicates', (req, res) => {
const PAGE_SIZE = 20;
const page = Math.max(1, parseInt(req.query.page ?? '1', 10));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? String(PAGE_SIZE), 10)));
const rawGroups = getGroups(db);
const groups = rawGroups.map(group =>
const total = rawGroups.length;
const start = (page - 1) * limit;
const slice = rawGroups.slice(start, start + limit);
const groups = slice.map(group =>
group.map(p => ({
id: p.id,
date_taken: p.date_taken,
@@ -582,7 +590,8 @@ app.get('/api/duplicates', (_req, res) => {
preview_url: posterUrl(p.preview_path),
}))
);
res.json({ groups, total: groups.length });
res.json({ groups, total, page, limit, pages: Math.ceil(total / limit) });
});
app.post('/api/duplicates/dismiss', (req, res) => {