4 Commits

Author SHA1 Message Date
a0c41e38d3 revert: drop By Date sidebar node and supporting code
Tried it, didn't add value beyond what the date-grouped timeline
already gives. The grouped, sticky-headered timeline (which kicks
in by default whenever sortBy is taken_at) is the better
affordance for date navigation — duplicating that as a sidebar
drilldown was just clutter.

Removes the full stack:
- LeftSidebar: by-date tree node, byDateChildren computation,
  date-year-/date-month- handlers in applyLibraryNode, isItemActive
  branches that matched a date-range filter, the now-unused
  setDateFrom/setDateTo/filterDateFrom/filterDateTo selectors, and
  the Calendar icon import.
- frontend/src/hooks/useDateBucketsQuery.ts deleted entirely.
- api.ts: library.dateBuckets helper and DateBucketYear/Month types.
- backend/app/routers/library.py: GET /library/date_buckets endpoint
  and its strftime aggregation query.

The dateFrom/dateTo filter state stays in filterStore — the
FilterBar still uses it for the "Date" range inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:19:59 +02:00
7d33e1688a feat: By Date sidebar — year/month drilldown navigation
The "By Date" library node was decorative. Now it's a real
hierarchical navigator: expand to see year buckets (with photo
counts), expand a year to see its months, click any year or
month to filter the timeline to that date range.

Backend
- New GET /library/date_buckets aggregates non-discarded photos
  by year+month from Photo.taken_at via SQLite strftime, returning
  [{ year, count, months: [{ month, count }] }] sorted newest-
  first. NULL taken_at rows are excluded.

Frontend
- New library.dateBuckets() helper + DateBucketYear / Month types.
- New hooks/useDateBucketsQuery.ts with a 60s staleTime.
- LeftSidebar builds the By Date subtree dynamically from the
  query: each year is a tree node with month children. Year nodes
  use the Calendar icon, months render as their full English name.
- applyLibraryNode handles two new id prefixes:
  'date-year-{year}'  → setDateFrom YYYY-01-01, setDateTo YYYY-12-31
  'date-month-{Y}-{M}'→ setDateFrom YYYY-MM-01, setDateTo YYYY-MM-LL
  where LL is the last day of the month (computed via Date trick
  new Date(year, month, 0).getDate() — uses month-day=0 to roll
  back into the previous month's last day).
- isItemActive recognises when the current dateFrom/dateTo matches
  a year or month node so the sidebar selection highlight stays
  in sync with the filter store (also when filters are set
  externally via the filter bar or URL hydration).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:14:57 +02:00
16481730b7 feat: drag photos onto a folder to move them
Bulk-move via drag-and-drop. Drop a photo (or multi-selection) on
any folder row in the LeftSidebar and the files move on disk +
photo.folder_id updates atomically.

Backend
- New POST /photos/move accepting { photo_ids, target_id }. The
  target_id can be either a Folder id OR a SourceRoot id (the
  sidebar exposes source roots today, so the same drag target
  needs to resolve either).
- Resolves source roots to their on-disk path and looks up / creates
  the canonical Folder row via the existing scan get_or_create_folder
  helper, so dedupe + path normalization stay consistent with the
  scanner.
- Per-photo loop with shutil.move; per-file failures (target name
  collision, missing source, OS error) are collected into a
  structured `errors` array and don't abort the batch.
- Skips photos that are already in the target folder so re-drops
  are a no-op.

Frontend
- New photos.move(ids, targetId) helper in api.ts.
- LeftSidebar grows a moveDropMutation alongside the existing
  discard one. handleDrop dispatches by id prefix:
  'discarded' → discard, 'folder-{id}' → move.
- Folder rows now report acceptsDrop and get the same drag-over
  highlight as heap drops, in primary tint instead of reject.
- onSuccess invalidates both the photos query and the folders
  query so the new folder counts in the sidebar refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:13:04 +02:00
066acb64ec feat: drag photos onto Discarded sidebar node to discard them
Same drag pattern as the heap drop, but the target is the Discarded
library node. The dropped photos go to is_discarded=true via a
single bulk request.

Backend: the /photos/bulk endpoint already had a 'discard' action
branch; the missing piece was a frontend client that sent the right
shape. The previous photos.bulkUpdate sent
{ photo_ids, discard: true } against a backend that wanted
{ ids, action } — silently broken since day one. Replaced with two
narrow helpers that match BulkAction exactly: photos.bulkDiscard(ids)
and photos.bulkRestore(ids).

Frontend: LeftSidebar grows a small dnd state machine — dropTargetId
for the hovered row, isDropTarget(id) for which library nodes accept
drops, handleDrop(id, ids) for the dispatch. Today only the
'discarded' node is wired; folder rows for bulk move come next.
Drop highlight uses the reject ring/tint to match the destructive
nature of the action. Toast confirms; photos query is invalidated
so the timeline immediately drops them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:11:23 +02:00
3 changed files with 209 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import json import json
@@ -485,6 +486,100 @@ async def discard_photo(
return {"status": "success", "message": "Photo discarded"} return {"status": "success", "message": "Photo discarded"}
class MoveRequest(BaseModel):
photo_ids: list[str]
target_id: str # folder id OR source root id
@router.post("/move")
async def move_photos(
body: MoveRequest,
db: AsyncSession = Depends(get_db),
):
"""Move photos into a target folder. The target can be either a Folder
id or a SourceRoot id (since the LeftSidebar only exposes source roots
today). The handler resolves the target to an on-disk directory, calls
shutil.move for each photo, and updates photo.filepath + folder_id.
Per-file failures (target name collision, missing source) are collected
and returned in the response so a single bad photo doesn't abort the
batch.
"""
import shutil
# 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
# We need a Folder row to point photo.folder_id at. Reuse the
# scanner's get_or_create helper so we don't duplicate the dedupe
# / normalization logic.
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}",
)
if not body.photo_ids:
return {"status": "success", "moved": 0, "errors": []}
# Fetch the photo rows
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
)
photos_to_move = photos_result.scalars().all()
moved = 0
errors: list[dict] = []
for photo in photos_to_move:
# Skip if already in the target folder.
if photo.folder_id == target_folder.id:
continue
new_path = os.path.join(target_dir, photo.filename)
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
if os.path.exists(new_path):
errors.append({"id": photo.id, "error": f"name already exists in target: {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
await db.commit()
return {
"status": "success",
"moved": moved,
"errors": errors,
}
@router.post("/bulk") @router.post("/bulk")
async def bulk_action( async def bulk_action(
action: BulkAction, action: BulkAction,

View File

@@ -4,7 +4,6 @@ import {
ChevronDown, ChevronDown,
Folder, Folder,
Image, Image,
Calendar,
Star, Star,
Trash2, Trash2,
Plus, Plus,
@@ -14,11 +13,12 @@ import {
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog' import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
import { sourceFolders, library } from '../../services/api' import { sourceFolders, library, photos as photosApi } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel' import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
interface TreeItem { interface TreeItem {
id: string id: string
@@ -41,6 +41,55 @@ export function LeftSidebar() {
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 filterFolderId = useFilterStore((s) => s.folderId) const filterFolderId = useFilterStore((s) => s.folderId)
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
onSuccess: (_data, photoIds) => {
toast.success(
'Discarded',
`${photoIds.length} photo${photoIds.length > 1 ? 's' : ''}`
)
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'),
})
// Bulk move mutation for the drag-onto-folder interaction.
const moveDropMutation = useMutation({
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
photosApi.move(photoIds, targetId),
onSuccess: (data) => {
const moved = data?.moved ?? 0
const errCount = (data?.errors?.length ?? 0)
if (moved > 0) {
toast.success(
'Moved',
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
)
} else if (errCount > 0) {
toast.error('Move failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be moved`)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Reads the dragged ids out of a drop event payload.
const readDragIds = (e: React.DragEvent): string[] | null => {
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as string[]
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
} catch {
return null
}
}
// Map a library tree id to a filter-store mutation. Each "virtual node" in // Map a library tree id to a filter-store mutation. Each "virtual node" in
// the library tree is just a saved filter preset. // the library tree is just a saved filter preset.
@@ -66,7 +115,6 @@ export function LeftSidebar() {
clearAllFilters() clearAllFilters()
setFolderId(folderId) setFolderId(folderId)
} }
// 'by-date' is still visual-only.
} }
} }
@@ -142,7 +190,6 @@ export function LeftSidebar() {
icon: <HardDrive className="h-4 w-4" />, icon: <HardDrive className="h-4 w-4" />,
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: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
{ 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: '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 },
], ],
@@ -175,17 +222,38 @@ export function LeftSidebar() {
return selectedItem === id return selectedItem === id
} }
// Which tree items accept photo drops, and what each does on drop.
const isDropTarget = (id: string): boolean => {
return id === 'discarded' || id.startsWith('folder-')
}
const handleDrop = (id: string, ids: string[]) => {
if (id === 'discarded') {
discardDropMutation.mutate(ids)
return
}
if (id.startsWith('folder-')) {
const targetId = id.slice('folder-'.length)
moveDropMutation.mutate({ targetId, photoIds: ids })
}
}
const renderTreeItem = (item: TreeItem, depth: number = 0) => { const renderTreeItem = (item: TreeItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0 const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.has(item.id) const isExpanded = expandedItems.has(item.id)
const isSelected = isItemActive(item.id) const isSelected = isItemActive(item.id)
const acceptsDrop = isDropTarget(item.id)
const isDropHover = dropTargetId === item.id
return ( return (
<div key={item.id}> <div key={item.id}>
<div <div
className={clsx( className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm', 'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2', isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
isDropHover && (item.id === 'discarded'
? 'ring-2 ring-reject bg-reject/10'
: 'ring-2 ring-primary bg-primary/10'),
depth > 0 && 'text-[13px]' depth > 0 && 'text-[13px]'
)} )}
style={{ paddingLeft: `${8 + depth * 16}px` }} style={{ paddingLeft: `${8 + depth * 16}px` }}
@@ -197,6 +265,24 @@ export function LeftSidebar() {
applyLibraryNode(item.id) applyLibraryNode(item.id)
} }
}} }}
onDragOver={acceptsDrop ? (e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
if (dropTargetId !== item.id) setDropTargetId(item.id)
}
} : undefined}
onDragLeave={acceptsDrop ? (e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === item.id) setDropTargetId(null)
}
} : undefined}
onDrop={acceptsDrop ? (e) => {
e.preventDefault()
setDropTargetId(null)
const ids = readDragIds(e)
if (ids) handleDrop(item.id, ids)
} : undefined}
> >
{/* Expand/Collapse Icon */} {/* Expand/Collapse Icon */}
{hasChildren ? ( {hasChildren ? (

View File

@@ -69,19 +69,34 @@ export const photos = {
return response.data return response.data
}, },
bulkUpdate: async (photoIds: string[], data: { /** Bulk discard — matches the backend BulkAction schema. */
rating?: number bulkDiscard: async (photoIds: string[]) => {
flag?: string
heap_id?: string
discard?: boolean
}) => {
const response = await api.post('/photos/bulk', { const response = await api.post('/photos/bulk', {
photo_ids: photoIds, ids: photoIds,
...data, action: 'discard',
}) })
return response.data return response.data
}, },
/** Bulk restore from discarded. */
bulkRestore: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'restore',
})
return response.data
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/move', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => { getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}` return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
}, },