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:
1 parent
4f7dd3b49b
commit
64cca095ea
3 files changed
+91
-39
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user