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 fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
import json
@@ -485,6 +486,100 @@ async def discard_photo(
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")
async def bulk_action(
action: BulkAction,

View File

@@ -4,7 +4,6 @@ import {
ChevronDown,
Folder,
Image,
Calendar,
Star,
Trash2,
Plus,
@@ -14,11 +13,12 @@ import {
} from 'lucide-react'
import clsx from 'clsx'
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 { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
interface TreeItem {
id: string
@@ -41,6 +41,55 @@ export function LeftSidebar() {
const setFlag = useFilterStore((s) => s.setFlag)
const setFolderId = useFilterStore((s) => s.setFolderId)
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
// the library tree is just a saved filter preset.
@@ -66,7 +115,6 @@ export function LeftSidebar() {
clearAllFilters()
setFolderId(folderId)
}
// 'by-date' is still visual-only.
}
}
@@ -142,7 +190,6 @@ export function LeftSidebar() {
icon: <HardDrive className="h-4 w-4" />,
children: [
{ 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: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
@@ -175,17 +222,38 @@ export function LeftSidebar() {
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 hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.has(item.id)
const isSelected = isItemActive(item.id)
const acceptsDrop = isDropTarget(item.id)
const isDropHover = dropTargetId === item.id
return (
<div key={item.id}>
<div
className={clsx(
'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',
isDropHover && (item.id === 'discarded'
? 'ring-2 ring-reject bg-reject/10'
: 'ring-2 ring-primary bg-primary/10'),
depth > 0 && 'text-[13px]'
)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
@@ -197,6 +265,24 @@ export function LeftSidebar() {
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 */}
{hasChildren ? (

View File

@@ -69,19 +69,34 @@ export const photos = {
return response.data
},
bulkUpdate: async (photoIds: string[], data: {
rating?: number
flag?: string
heap_id?: string
discard?: boolean
}) => {
/** Bulk discard — matches the backend BulkAction schema. */
bulkDiscard: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
photo_ids: photoIds,
...data,
ids: photoIds,
action: 'discard',
})
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') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
},