import { useMemo, useState, useEffect, useCallback } from 'react' import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react' import clsx from 'clsx' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useDuplicateGroupsQuery, DUPLICATE_GROUPS_QUERY_KEY, } from '../../hooks/useDuplicateGroupsQuery' import { photos as photosApi, type DuplicateGroup, type DuplicateGroupMember, } from '../../services/api' import { PhotoThumbnail } from '../timeline/PhotoThumbnail' import { usePhotoStore } from '../../store/photoStore' import { registerUndoable } from '../../store/undoStore' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { toast } from '../ToastContainer' import type { Photo } from '../../types/photo' /** * Sectioned grid view of duplicate clusters. Replaces the old flat * "is_duplicate=true" timeline. Each section is one cluster the * regroup_duplicates task identified — header on top with a count and a * "keep best, discard rest" button, members rendered as PhotoThumbnail * cards below. * * Mounted from App.tsx in place of when the user is in the * duplicates section. Touches no filter store state. */ export function DuplicatesView() { const { data, isLoading, isError, error } = useDuplicateGroupsQuery() const queryClient = useQueryClient() const openPreview = usePhotoStore((s) => s.openPreview) const selectPhoto = usePhotoStore((s) => s.selectPhoto) const selectedPhotos = usePhotoStore((s) => s.selectedPhotos) const activePhotoId = usePhotoStore((s) => s.activePhotoId) // Bulk discard with the same undoable wrapper the timeline uses, so // Cmd+Z restores the discarded copies. invalidate ['library', 'duplicates'] // so the group disappears from the view immediately. const discardMutation = useMutation({ mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), onSuccess: (_, ids) => { registerUndoable( `Discarded ${ids.length} duplicate${ids.length === 1 ? '' : 's'}`, async () => { await photosApi.bulkRestore(ids) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } ) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) }, onError: (e: any) => toast.error('Discard failed', e?.message || 'Unknown error'), }) // Hooks below this point must run on every render — rules of hooks // forbid early returns above any useState/useEffect/useMemo. The early // loading/error/empty branches sit AFTER the hook block. const groups = data?.groups ?? [] // Flat sequence of member ids in visual order. Drives both preview // navigation and the in-grid keyboard walker. useMemo so the keyboard // effect doesn't tear down on every render. const allMemberIds = useMemo( () => groups.flatMap((g) => g.members.map((m) => m.id)), [groups] ) // Track the rendered column count of the duplicates grid so ↑/↓ can // skip a row instead of jumping a single cell. The grid uses // `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180). // We measure the FIRST section's grid container — every section uses // the same auto-fill rule so any one is representative. const [columns, setColumns] = useState(4) const sampleGridRef = useCallback((el: HTMLDivElement | null) => { if (!el) return const measure = () => { const cols = Math.max(1, Math.floor(el.clientWidth / 180)) setColumns(cols) } measure() const ro = new ResizeObserver(measure) ro.observe(el) // Caller doesn't get the cleanup hook but ResizeObserver disconnects // when the element unmounts, which is fine for our lifecycle. }, []) // Window-level keyboard nav. Mirrors Timeline's handler but walks // `allMemberIds` directly — duplicate groups don't have a uniform row // grid so we approximate ↑/↓ via the measured `columns` count and // wrap ←/→ across group boundaries. useEffect(() => { if (allMemberIds.length === 0) return const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { return } const key = e.key if ( key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' ) { return } e.preventDefault() const currentIdx = activePhotoId ? allMemberIds.indexOf(activePhotoId) : -1 const startIdx = currentIdx >= 0 ? currentIdx : 0 let nextIdx = startIdx if (key === 'ArrowLeft') nextIdx = startIdx - 1 else if (key === 'ArrowRight') nextIdx = startIdx + 1 else if (key === 'ArrowUp') nextIdx = startIdx - columns else if (key === 'ArrowDown') nextIdx = startIdx + columns // Clamp to bounds — we don't wrap on out-of-range vertical moves // since the grid is partitioned into groups and a "wrap" would // skip across visually unrelated content. nextIdx = Math.max(0, Math.min(allMemberIds.length - 1, nextIdx)) const nextId = allMemberIds[nextIdx] if (!nextId) return selectPhoto(nextId) // Scroll the now-active cell into view if it's off-screen. The // PhotoThumbnail wrapper carries data-dup-id so we can find it // without threading refs through every cell. const el = document.querySelector( `[data-dup-id="${nextId}"]` ) el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [allMemberIds, activePhotoId, columns, selectPhoto]) if (isLoading) { return (
Loading duplicate groups…
) } if (isError) { return (
Could not load duplicate groups: {(error as any)?.message ?? 'unknown error'}
) } if (groups.length === 0) { return (
No duplicates found

Nothing in your library matches another photo at the perceptual-hash level. If you've just added new photos, give the worker a minute and re-run "Re-detect duplicates" from Settings.

) } return (
{data?.total_groups} group{data?.total_groups === 1 ? '' : 's'} ·{' '} {data?.total_members} photo{data?.total_members === 1 ? '' : 's'}. Click "Keep best" to auto-discard all but the highest-resolution copy of each group. Cmd+Z to undo.
{groups.map((group, idx) => ( discardMutation.mutate(discardIds)} onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)} onSelectMember={(memberId) => selectPhoto(memberId)} selectedPhotos={selectedPhotos} isPending={discardMutation.isPending} // Hand the column-measurement ref to the first section only // — every section's grid uses the same auto-fill rule so any // one is representative of the rendered column count. gridRef={idx === 0 ? sampleGridRef : undefined} /> ))}
) } interface DuplicateGroupSectionProps { group: DuplicateGroup onKeepBest: (discardIds: string[]) => void onPreviewMember: (memberId: string) => void onSelectMember: (memberId: string) => void selectedPhotos: string[] isPending: boolean /** Optional callback ref attached to this section's grid container. * Used by DuplicatesView to measure the rendered column count for * ↑/↓ keyboard navigation. Only the first section gets one. */ gridRef?: (el: HTMLDivElement | null) => void } function DuplicateGroupSection({ group, onKeepBest, onPreviewMember, onSelectMember, selectedPhotos, isPending, gridRef, }: DuplicateGroupSectionProps) { // Auto-pick "best" copy: highest pixel count, ties broken by file_size, // then earliest taken_at, then id for determinism. This is just the // default — the user can override it by clicking the crown button on // any other thumbnail (see `manualBestId`). const autoBest = useMemo(() => pickBestMember(group.members), [group.members]) // When the user clicks "make this the best" on a non-default thumb, // we override the auto-pick. Local to the section so different groups // remember independent overrides; resets if the group itself changes. const [manualBestId, setManualBestId] = useState(null) const bestId = manualBestId && group.members.some((m) => m.id === manualBestId) ? manualBestId : autoBest.id const best = group.members.find((m) => m.id === bestId) ?? autoBest const discardCount = group.member_count - 1 const isExact = group.reason === 'exact' return (
{isExact ? ( ) : ( )} {group.member_count} {isExact ? 'exact' : 'similar'} photos keeping: {formatDimensions(best)} {best.file_size != null && ` · ${formatBytes(best.file_size)}`} {manualBestId && manualBestId !== autoBest.id && ( (manual) )}
{group.members.map((member) => { const isBest = member.id === best.id return (
onSelectMember(member.id)} onDoubleClick={() => onPreviewMember(member.id)} /> {/* "BEST" pill marks the photo that will be KEPT when the * user clicks the discard button. Lives at top-right where * it doesn't collide with the cyan selection ring (which * draws around the cell perimeter); kept inset by 6px so * the ring's outer edge has clearance on either side. */} {isBest && ( Best )} {/* "Make this best" affordance — shown on hover for non-best * thumbnails. Mirrors the BEST pill's top-right placement * so the eye doesn't have to retarget when the user is * scanning a row of thumbnails. Stops propagation so it * doesn't double-fire as a selection click. */} {!isBest && ( )} {/* Dimensions chip — bottom-LEFT (the BEST/Keep affordances * own the top-right corner). */}
{formatDimensions(member)}
) })}
) } // ── Helpers ────────────────────────────────────────────────────────────── /** Score a member by (pixels, file_size, -taken_at) and return the winner. * Larger pixel count wins; ties broken by file_size; final tie by earliest * taken_at (more likely the original capture). */ function pickBestMember(members: DuplicateGroupMember[]): DuplicateGroupMember { return members.reduce((best, m) => { const bestPixels = (best.width ?? 0) * (best.height ?? 0) const mPixels = (m.width ?? 0) * (m.height ?? 0) if (mPixels !== bestPixels) return mPixels > bestPixels ? m : best const bestSize = best.file_size ?? 0 const mSize = m.file_size ?? 0 if (mSize !== bestSize) return mSize > bestSize ? m : best // Earliest taken_at wins (treat null as far-future). const bestTaken = best.taken_at ?? '9999' const mTaken = m.taken_at ?? '9999' if (mTaken !== bestTaken) return mTaken < bestTaken ? m : best return best }) } function formatDimensions(m: DuplicateGroupMember): string { if (!m.width || !m.height) return '?' const mp = (m.width * m.height) / 1_000_000 if (mp >= 1) return `${mp.toFixed(1)}MP` return `${m.width}×${m.height}` } function formatBytes(n: number): string { if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB` if (n >= 1024) return `${(n / 1024).toFixed(0)}KB` return `${n}B` } /** Adapt a DuplicateGroupMember (the slim API shape) to a Photo, which * is what PhotoThumbnail expects. We deliberately set is_duplicate=false * on the synthetic Photo so the duplicate badge isn't drawn on every * cell — the entire view is duplicates, the badge would be redundant. */ function memberToPhoto(m: DuplicateGroupMember): Photo { return { id: m.id, filepath: m.filename, // good enough for the RAW/video extension regex filename: m.filename, media_type: m.media_type, width: m.width, height: m.height, taken_at: m.taken_at, rating: 0, is_discarded: false, is_duplicate: false, file_hash: m.file_hash ?? '', folder_id: m.folder_id, added_at: null, thumb_small: m.thumb_small ?? undefined, } }