diff --git a/client/src/api/photos.js b/client/src/api/photos.js index b533f12..486094f 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -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() { diff --git a/client/src/components/DuplicatesView.jsx b/client/src/components/DuplicatesView.jsx index 78a54d6..31d0ace 100644 --- a/client/src/components/DuplicatesView.jsx +++ b/client/src/components/DuplicatesView.jsx @@ -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 (
{/* Toolbar */} @@ -92,32 +115,52 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {

{error}

)} - {groups === null && ( + {loading && (

Loading…

)} - {groups !== null && groups.length === 0 && ( + {!loading && total === 0 && (

No similar photos to review.

Click "Scan library" to compare perceptual hashes across your library.

)} - {groups !== null && groups.length > 0 && ( -
-

- {groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss + {!loading && total > 0 && ( + <> +

+ {total} group{total !== 1 ? 's' : ''} — showing {groups.length} + {' · '}click a photo to inspect it

- {groups.map((group, i) => ( - handleDismiss(group.map(p => p.id))} - onKeepBest={() => handleKeepBest(group.map(p => p.id))} - onOpen={(photo) => onOpenPhoto(photo, group)} - /> - ))} -
+ +
+ {groups.map((group, i) => ( + handleDismiss(group.map(p => p.id))} + onKeepBest={() => handleKeepBest(group.map(p => p.id))} + onOpen={(photo) => onOpenPhoto(photo, group)} + /> + ))} +
+ + {hasMore && ( +
+ +
+ )} + )}
); diff --git a/server/index.js b/server/index.js index b21acac..481d02d 100644 --- a/server/index.js +++ b/server/index.js @@ -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) => {