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
+82 -30

No files matched your search

+2 -2
View File
@@ -172,8 +172,8 @@ export function bulkAddToCollection(ids, collectionId) {
} }
// Duplicates // Duplicates
export function fetchDuplicates() { export function fetchDuplicates(page = 1, limit = 20) {
return api('/api/duplicates'); return api(`/api/duplicates?page=${page}&limit=${limit}`);
} }
export function scanDuplicates() { export function scanDuplicates() {
+68 -25
View File
@@ -2,44 +2,60 @@ import { useState, useEffect } from 'react';
import { fetchDuplicates, scanDuplicates, dismissGroup, keepBestDuplicate } from '../api/photos.js'; import { fetchDuplicates, scanDuplicates, dismissGroup, keepBestDuplicate } from '../api/photos.js';
import DuplicateGroup from './DuplicateGroup.jsx'; import DuplicateGroup from './DuplicateGroup.jsx';
const PAGE_SIZE = 20;
export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) { 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 [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState(null); const [scanResult, setScanResult] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { load(); }, []); async function loadPage(p, replace = false) {
async function load() {
try { try {
const data = await fetchDuplicates(); const data = await fetchDuplicates(p, PAGE_SIZE);
setGroups(data.groups); setTotal(data.total);
setPage(p);
setHasMore(p < data.pages);
setGroups(prev => replace ? data.groups : [...prev, ...data.groups]);
onGroupCountChange?.(data.total); onGroupCountChange?.(data.total);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} }
} }
async function handleLoadMore() {
setLoadingMore(true);
await loadPage(page + 1, false);
setLoadingMore(false);
}
async function handleScan() { async function handleScan() {
setScanning(true); setScanning(true);
setError(null); setError(null);
try { try {
const result = await scanDuplicates(); const result = await scanDuplicates();
setScanResult(result); setScanResult(result);
await load(); setLoading(true);
await loadPage(1, true);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} } finally {
setScanning(false); setScanning(false);
setLoading(false);
}
} }
function removeGroup(photoIds) { function removeGroupLocal(photoIds) {
setGroups(prev => { const ids = new Set(photoIds);
const next = prev.filter(g => { setGroups(prev => prev.filter(g => !photoIds.every(id => new Set(g.map(p => p.id)).has(id))));
const s = new Set(g.map(p => p.id)); setTotal(prev => {
return !photoIds.every(id => s.has(id)); const next = (prev ?? 1) - 1;
}); onGroupCountChange?.(next);
onGroupCountChange?.(next.length);
return next; return next;
}); });
} }
@@ -47,19 +63,26 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
async function handleDismiss(photoIds) { async function handleDismiss(photoIds) {
try { try {
await dismissGroup(photoIds); await dismissGroup(photoIds);
removeGroup(photoIds); removeGroupLocal(photoIds);
} catch {} } catch (e) {
setError(e.message);
}
} }
async function handleKeepBest(photoIds) { async function handleKeepBest(photoIds) {
try { try {
await keepBestDuplicate(photoIds); await keepBestDuplicate(photoIds);
removeGroup(photoIds); removeGroupLocal(photoIds);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} }
} }
// Initial load
useEffect(() => {
loadPage(1, true).finally(() => setLoading(false));
}, []);
return ( return (
<div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}> <div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}>
{/* Toolbar */} {/* Toolbar */}
@@ -92,25 +115,28 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
<p style={{ color: '#f87171', fontSize: 13, marginBottom: 16 }}>{error}</p> <p style={{ color: '#f87171', fontSize: 13, marginBottom: 16 }}>{error}</p>
)} )}
{groups === null && ( {loading && (
<p style={{ color: '#777', fontSize: 13 }}>Loading…</p> <p style={{ color: '#777', fontSize: 13 }}>Loading…</p>
)} )}
{groups !== null && groups.length === 0 && ( {!loading && total === 0 && (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#777' }}> <div style={{ textAlign: 'center', paddingTop: 60, color: '#777' }}>
<p style={{ fontSize: 15, marginBottom: 8 }}>No similar photos to review.</p> <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> <p style={{ fontSize: 13 }}>Click "Scan library" to compare perceptual hashes across your library.</p>
</div> </div>
)} )}
{groups !== null && groups.length > 0 && ( {!loading && total > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <>
<p style={{ fontSize: 12, color: '#666', marginBottom: 4 }}> <p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
{groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss {total} group{total !== 1 ? 's' : ''} — showing {groups.length}
{' · '}click a photo to inspect it
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{groups.map((group, i) => ( {groups.map((group, i) => (
<DuplicateGroup <DuplicateGroup
key={i} key={`${group[0]?.id}-${i}`}
photos={group} photos={group}
onDismiss={() => handleDismiss(group.map(p => p.id))} onDismiss={() => handleDismiss(group.map(p => p.id))}
onKeepBest={() => handleKeepBest(group.map(p => p.id))} onKeepBest={() => handleKeepBest(group.map(p => p.id))}
@@ -118,6 +144,23 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
/> />
))} ))}
</div> </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> </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 }); } } 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 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 => ({ group.map(p => ({
id: p.id, id: p.id,
date_taken: p.date_taken, date_taken: p.date_taken,
@@ -582,7 +590,8 @@ app.get('/api/duplicates', (_req, res) => {
preview_url: posterUrl(p.preview_path), 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) => { app.post('/api/duplicates/dismiss', (req, res) => {