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
+82
-30
No files matched your search
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in new issue
Block a user