feat: heap convert-to-folder + surface exact-duplicate detection

Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:28:18 +02:00
parent bc1e63095c
commit bed817d274
11 changed files with 421 additions and 6 deletions

View File

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface HeapConvertDialogProps {
heap: Heap | null
onClose: () => void
}
/**
* Modal that converts a heap into a folder. The user picks a target folder
* (any source root, today — sub-folder picking is a follow-up), chooses
* move vs copy semantics, and optionally has the heap deleted on success.
*/
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('')
const [mode, setMode] = useState<'move' | 'copy'>('move')
const [deleteHeap, setDeleteHeap] = useState(false)
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
enabled: !!heap,
})
const folders = foldersData?.folders ?? []
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
if (!targetId && folders.length > 0) {
setTargetId(folders[0].id)
}
}, [folders, targetId])
// Reset state on close.
useEffect(() => {
if (!heap) {
setTargetId('')
setMode('move')
setDeleteHeap(false)
}
}, [heap])
const convertMutation = useMutation({
mutationFn: () =>
heapsApi.convert(heap!.id, {
target_id: targetId,
mode,
delete_heap: deleteHeap,
}),
onSuccess: (data) => {
const total = (data.moved ?? 0) + (data.copied ?? 0)
const verb = data.mode === 'move' ? 'Moved' : 'Copied'
toast.success(
`${verb} ${total} photo${total === 1 ? '' : 's'}`,
data.heap_deleted ? `Heap "${heap?.name}" deleted` : undefined
)
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
onClose()
},
onError: (e: any) =>
toast.error('Convert failed', e?.response?.data?.detail || e.message),
})
if (!heap) return null
const targetFolder = folders.find((f: any) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f: any) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => setDeleteHeap(e.target.checked)}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -6,14 +6,16 @@ import {
X,
ChevronDown,
ChevronRight,
FolderOutput,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { heaps as heapsApi, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -37,6 +39,7 @@ export function HeapsPanel() {
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -277,6 +280,16 @@ export function HeapsPanel() {
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
@@ -294,6 +307,11 @@ export function HeapsPanel() {
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
MoreHorizontal,
HardDrive,
RefreshCw,
Copy,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi } from '../../services/api'
@@ -41,7 +42,9 @@ export function LeftSidebar() {
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setFlag = useFilterStore((s) => s.setFlag)
const setFolderId = useFilterStore((s) => s.setFolderId)
const setDuplicates = useFilterStore((s) => s.setDuplicates)
const filterFolderId = useFilterStore((s) => s.folderId)
const filterDuplicates = useFilterStore((s) => s.duplicates)
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -129,6 +132,10 @@ export function LeftSidebar() {
clearAllFilters()
setFlag('discarded')
break
case 'duplicates':
clearAllFilters()
setDuplicates(true)
break
default:
if (id.startsWith('folder-')) {
// Folder rows: filter to that folder, clear other filters that
@@ -199,6 +206,7 @@ export function LeftSidebar() {
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
@@ -227,6 +235,9 @@ export function LeftSidebar() {
if (id === 'all-photos') {
return filterFolderId === null && selectedItem === 'all-photos'
}
if (id === 'duplicates') {
return filterDuplicates
}
return selectedItem === id
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -207,6 +207,14 @@ export function PhotoThumbnail({
<ShoppingBasket className="h-3 w-3" />
</div>
)}
{photo.is_duplicate && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
title="Duplicate (matches another photo's hash)"
>
<Copy className="h-3 w-3" />
</div>
)}
{photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" />
)}

View File

@@ -77,6 +77,8 @@ function parseUrl(): Partial<FilterState> {
if (ids.length > 0) out.tagIds = ids
}
if (sp.get('duplicates') === 'true') out.duplicates = true
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
out.sortBy = sortBy as SortField
@@ -102,6 +104,7 @@ function writeUrl(f: FilterState) {
if (f.heapId) sp.set('heap_id', f.heapId)
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)

View File

@@ -21,6 +21,7 @@ export function usePhotosQuery() {
const heapId = useFilterStore((s) => s.heapId)
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -37,10 +38,11 @@ export function usePhotosQuery() {
heapId,
folderId,
tagIds,
duplicates,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, sortBy, sortOrder]
)
return useQuery({

View File

@@ -188,6 +188,23 @@ export const heaps = {
})
return response.data
},
/** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. */
convert: async (
heapId: string,
body: { target_id: string; mode: 'move' | 'copy'; delete_heap: boolean }
) => {
const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as {
status: string
mode: 'move' | 'copy'
moved: number
copied: number
errors: Array<{ id: string; error: string }>
heap_deleted: boolean
}
},
}
// Tags API

View File

@@ -26,6 +26,8 @@ export interface FilterState {
folderId: string | null
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
sortBy: SortField
sortOrder: SortOrder
}
@@ -44,6 +46,7 @@ interface FilterStore extends FilterState {
setFolderId: (id: string | null) => void
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
@@ -66,6 +69,7 @@ export const INITIAL_FILTERS: FilterState = {
heapId: null,
folderId: null,
tagIds: [],
duplicates: false,
sortBy: 'taken_at',
sortOrder: 'desc',
}
@@ -95,6 +99,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
? s.tagIds.filter((t) => t !== id)
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
@@ -121,6 +126,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.heapId) params.heap_id = f.heapId
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
if (f.duplicates) params.is_duplicate = 'true'
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -138,6 +144,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.flag !== 'any' ||
f.heapId !== null ||
f.folderId !== null ||
f.tagIds.length > 0
f.tagIds.length > 0 ||
f.duplicates
)
}

View File

@@ -8,6 +8,7 @@ export interface Photo {
taken_at: string | null
rating: number
is_discarded: boolean
is_duplicate: boolean
file_hash: string
thumb_small?: string
thumb_medium?: string