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 (
{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.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) => ( -