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:
@@ -1,16 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Heaps API router
|
Heaps API router
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
import os
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Literal
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, func, update, insert, delete
|
from sqlalchemy import select, func, update, insert, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Heap
|
from app.models import Heap, Photo, Folder
|
||||||
|
from app.models.folders import SourceRoot
|
||||||
from app.models.heaps import heap_photos
|
from app.models.heaps import heap_photos
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +35,12 @@ class HeapPhotosBody(BaseModel):
|
|||||||
photo_ids: list[str]
|
photo_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HeapConvertBody(BaseModel):
|
||||||
|
target_id: str # folder id OR source root id
|
||||||
|
mode: Literal['move', 'copy'] = 'move'
|
||||||
|
delete_heap: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -180,6 +192,141 @@ async def add_photos_to_heap(
|
|||||||
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{heap_id}/convert")
|
||||||
|
async def convert_heap_to_folder(
|
||||||
|
heap_id: str,
|
||||||
|
body: HeapConvertBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Convert a heap into a folder by moving (or copying) every member
|
||||||
|
photo into the target directory. Optionally deletes the heap row at
|
||||||
|
the end.
|
||||||
|
|
||||||
|
target_id may be a Folder id or a SourceRoot id (matches the
|
||||||
|
/photos/move convention so the same dropdown can populate it).
|
||||||
|
"""
|
||||||
|
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||||
|
heap = heap_result.scalar_one_or_none()
|
||||||
|
if not heap:
|
||||||
|
raise HTTPException(status_code=404, detail="Heap not found")
|
||||||
|
|
||||||
|
# Resolve target_id → (target_dir, target_folder)
|
||||||
|
sr_check = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||||
|
)
|
||||||
|
source_root = sr_check.scalar_one_or_none()
|
||||||
|
|
||||||
|
if source_root is not None:
|
||||||
|
target_dir = source_root.path
|
||||||
|
from app.tasks.scan import get_or_create_folder
|
||||||
|
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
|
||||||
|
else:
|
||||||
|
folder_check = await db.execute(
|
||||||
|
select(Folder).where(Folder.id == body.target_id)
|
||||||
|
)
|
||||||
|
target_folder = folder_check.scalar_one_or_none()
|
||||||
|
if target_folder is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||||
|
target_dir = target_folder.path
|
||||||
|
|
||||||
|
if not os.path.isdir(target_dir):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Target directory does not exist: {target_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch the heap's photos via the join table.
|
||||||
|
photo_result = await db.execute(
|
||||||
|
select(Photo)
|
||||||
|
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
|
||||||
|
.where(heap_photos.c.heap_id == heap_id)
|
||||||
|
)
|
||||||
|
photos = photo_result.scalars().all()
|
||||||
|
|
||||||
|
moved = 0
|
||||||
|
copied = 0
|
||||||
|
errors: list[dict] = []
|
||||||
|
|
||||||
|
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
||||||
|
if not os.path.exists(os.path.join(directory, filename)):
|
||||||
|
return filename
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
for i in range(1, 100):
|
||||||
|
suffix = '' if i == 1 else f' {i}'
|
||||||
|
candidate = f"{stem} (copy{suffix}){ext}"
|
||||||
|
if not os.path.exists(os.path.join(directory, candidate)):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
for photo in photos:
|
||||||
|
if not os.path.exists(photo.filepath):
|
||||||
|
errors.append({"id": photo.id, "error": "source file missing"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if body.mode == 'move':
|
||||||
|
if photo.folder_id == target_folder.id:
|
||||||
|
continue # already there
|
||||||
|
new_path = os.path.join(target_dir, photo.filename)
|
||||||
|
if os.path.exists(new_path):
|
||||||
|
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shutil.move(photo.filepath, new_path)
|
||||||
|
except OSError as e:
|
||||||
|
errors.append({"id": photo.id, "error": str(e)})
|
||||||
|
continue
|
||||||
|
photo.filepath = new_path
|
||||||
|
photo.folder_id = target_folder.id
|
||||||
|
moved += 1
|
||||||
|
else: # copy
|
||||||
|
new_name = _unique_target_name(target_dir, photo.filename)
|
||||||
|
if new_name is None:
|
||||||
|
errors.append({"id": photo.id, "error": "too many name collisions"})
|
||||||
|
continue
|
||||||
|
new_path = os.path.join(target_dir, new_name)
|
||||||
|
try:
|
||||||
|
shutil.copy2(photo.filepath, new_path)
|
||||||
|
except OSError as e:
|
||||||
|
errors.append({"id": photo.id, "error": str(e)})
|
||||||
|
continue
|
||||||
|
new_photo = Photo(
|
||||||
|
filepath=new_path,
|
||||||
|
filename=new_name,
|
||||||
|
folder_id=target_folder.id,
|
||||||
|
file_hash=photo.file_hash,
|
||||||
|
media_type=photo.media_type,
|
||||||
|
original_format=photo.original_format,
|
||||||
|
width=photo.width,
|
||||||
|
height=photo.height,
|
||||||
|
file_size=photo.file_size,
|
||||||
|
taken_at=photo.taken_at,
|
||||||
|
taken_at_source=photo.taken_at_source,
|
||||||
|
user_title=photo.user_title,
|
||||||
|
user_notes=photo.user_notes,
|
||||||
|
rating=photo.rating,
|
||||||
|
color_label=photo.color_label,
|
||||||
|
exif_json=photo.exif_json,
|
||||||
|
is_duplicate=True,
|
||||||
|
processing_status='pending',
|
||||||
|
)
|
||||||
|
db.add(new_photo)
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
if body.delete_heap:
|
||||||
|
await db.delete(heap)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"mode": body.mode,
|
||||||
|
"moved": moved,
|
||||||
|
"copied": copied,
|
||||||
|
"errors": errors,
|
||||||
|
"heap_deleted": body.delete_heap,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{heap_id}/photos")
|
@router.delete("/{heap_id}/photos")
|
||||||
async def remove_photos_from_heap(
|
async def remove_photos_from_heap(
|
||||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async def list_photos(
|
|||||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||||
color_label: Optional[str] = None,
|
color_label: Optional[str] = None,
|
||||||
is_discarded: Optional[bool] = False,
|
is_discarded: Optional[bool] = False,
|
||||||
|
is_duplicate: Optional[bool] = None,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
@@ -113,6 +114,11 @@ async def list_photos(
|
|||||||
# Discard filter — defaults to hiding discarded photos
|
# Discard filter — defaults to hiding discarded photos
|
||||||
filters.append(Photo.is_discarded == is_discarded)
|
filters.append(Photo.is_discarded == is_discarded)
|
||||||
|
|
||||||
|
# Duplicate filter — only applied when explicitly set, so the default
|
||||||
|
# view shows everything regardless of duplicate status.
|
||||||
|
if is_duplicate is not None:
|
||||||
|
filters.append(Photo.is_duplicate == is_duplicate)
|
||||||
|
|
||||||
# Heap membership filter — restrict to photos that belong to the heap.
|
# Heap membership filter — restrict to photos that belong to the heap.
|
||||||
if heap_id:
|
if heap_id:
|
||||||
filters.append(
|
filters.append(
|
||||||
|
|||||||
195
frontend/src/components/heaps/HeapConvertDialog.tsx
Normal file
195
frontend/src/components/heaps/HeapConvertDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,14 +6,16 @@ import {
|
|||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FolderOutput,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
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 { useFilterStore } from '../../store/filterStore'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { HeapConvertDialog } from './HeapConvertDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
* 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
|
// 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.
|
// the drop highlight ring. Only one heap can be the target at a time.
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -277,6 +280,16 @@ export function HeapsPanel() {
|
|||||||
>
|
>
|
||||||
<Target className="h-3 w-3" />
|
<Target className="h-3 w-3" />
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -294,6 +307,11 @@ export function HeapsPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<HeapConvertDialog
|
||||||
|
heap={convertingHeap}
|
||||||
|
onClose={() => setConvertingHeap(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||||
@@ -41,7 +42,9 @@ export function LeftSidebar() {
|
|||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
const setFolderId = useFilterStore((s) => s.setFolderId)
|
||||||
|
const setDuplicates = useFilterStore((s) => s.setDuplicates)
|
||||||
const filterFolderId = useFilterStore((s) => s.folderId)
|
const filterFolderId = useFilterStore((s) => s.folderId)
|
||||||
|
const filterDuplicates = useFilterStore((s) => s.duplicates)
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
@@ -129,6 +132,10 @@ export function LeftSidebar() {
|
|||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setFlag('discarded')
|
setFlag('discarded')
|
||||||
break
|
break
|
||||||
|
case 'duplicates':
|
||||||
|
clearAllFilters()
|
||||||
|
setDuplicates(true)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
// Folder rows: filter to that folder, clear other filters that
|
// Folder rows: filter to that folder, clear other filters that
|
||||||
@@ -199,6 +206,7 @@ export function LeftSidebar() {
|
|||||||
children: [
|
children: [
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
{ 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: '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 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -227,6 +235,9 @@ export function LeftSidebar() {
|
|||||||
if (id === 'all-photos') {
|
if (id === 'all-photos') {
|
||||||
return filterFolderId === null && selectedItem === 'all-photos'
|
return filterFolderId === null && selectedItem === 'all-photos'
|
||||||
}
|
}
|
||||||
|
if (id === 'duplicates') {
|
||||||
|
return filterDuplicates
|
||||||
|
}
|
||||||
return selectedItem === id
|
return selectedItem === id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
@@ -207,6 +207,14 @@ export function PhotoThumbnail({
|
|||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
</div>
|
</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 && (
|
{photo.is_discarded && (
|
||||||
<Trash2 className="h-4 w-4 text-reject" />
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
if (ids.length > 0) out.tagIds = ids
|
if (ids.length > 0) out.tagIds = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sp.get('duplicates') === 'true') out.duplicates = true
|
||||||
|
|
||||||
const sortBy = sp.get('sort')
|
const sortBy = sp.get('sort')
|
||||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||||
out.sortBy = 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.heapId) sp.set('heap_id', f.heapId)
|
||||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
if (f.folderId) sp.set('folder_id', f.folderId)
|
||||||
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
|
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.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function usePhotosQuery() {
|
|||||||
const heapId = useFilterStore((s) => s.heapId)
|
const heapId = useFilterStore((s) => s.heapId)
|
||||||
const folderId = useFilterStore((s) => s.folderId)
|
const folderId = useFilterStore((s) => s.folderId)
|
||||||
const tagIds = useFilterStore((s) => s.tagIds)
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
|
const duplicates = useFilterStore((s) => s.duplicates)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
|
|
||||||
@@ -37,10 +38,11 @@ export function usePhotosQuery() {
|
|||||||
heapId,
|
heapId,
|
||||||
folderId,
|
folderId,
|
||||||
tagIds,
|
tagIds,
|
||||||
|
duplicates,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
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({
|
return useQuery({
|
||||||
|
|||||||
@@ -188,6 +188,23 @@ export const heaps = {
|
|||||||
})
|
})
|
||||||
return response.data
|
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
|
// Tags API
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface FilterState {
|
|||||||
folderId: string | null
|
folderId: string | null
|
||||||
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
|
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
|
||||||
tagIds: string[]
|
tagIds: string[]
|
||||||
|
/** When true, restrict to photos flagged as duplicates by the scanner. */
|
||||||
|
duplicates: boolean
|
||||||
sortBy: SortField
|
sortBy: SortField
|
||||||
sortOrder: SortOrder
|
sortOrder: SortOrder
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ interface FilterStore extends FilterState {
|
|||||||
setFolderId: (id: string | null) => void
|
setFolderId: (id: string | null) => void
|
||||||
setTagIds: (ids: string[]) => void
|
setTagIds: (ids: string[]) => void
|
||||||
toggleTagId: (id: string) => void
|
toggleTagId: (id: string) => void
|
||||||
|
setDuplicates: (v: boolean) => void
|
||||||
setSortBy: (field: SortField) => void
|
setSortBy: (field: SortField) => void
|
||||||
setSortOrder: (order: SortOrder) => void
|
setSortOrder: (order: SortOrder) => void
|
||||||
toggleSortOrder: () => void
|
toggleSortOrder: () => void
|
||||||
@@ -66,6 +69,7 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
heapId: null,
|
heapId: null,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
|
duplicates: false,
|
||||||
sortBy: 'taken_at',
|
sortBy: 'taken_at',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
? s.tagIds.filter((t) => t !== id)
|
? s.tagIds.filter((t) => t !== id)
|
||||||
: [...s.tagIds, id],
|
: [...s.tagIds, id],
|
||||||
})),
|
})),
|
||||||
|
setDuplicates: (duplicates) => set({ duplicates }),
|
||||||
setSortBy: (sortBy) => set({ sortBy }),
|
setSortBy: (sortBy) => set({ sortBy }),
|
||||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||||
toggleSortOrder: () =>
|
toggleSortOrder: () =>
|
||||||
@@ -121,6 +126,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.heapId) params.heap_id = f.heapId
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
if (f.folderId) params.folder_id = f.folderId
|
if (f.folderId) params.folder_id = f.folderId
|
||||||
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
||||||
|
if (f.duplicates) params.is_duplicate = 'true'
|
||||||
params.sort = f.sortBy
|
params.sort = f.sortBy
|
||||||
params.order = f.sortOrder
|
params.order = f.sortOrder
|
||||||
return params
|
return params
|
||||||
@@ -138,6 +144,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.flag !== 'any' ||
|
f.flag !== 'any' ||
|
||||||
f.heapId !== null ||
|
f.heapId !== null ||
|
||||||
f.folderId !== null ||
|
f.folderId !== null ||
|
||||||
f.tagIds.length > 0
|
f.tagIds.length > 0 ||
|
||||||
|
f.duplicates
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Photo {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user