diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py
index e7c3791..06e5752 100644
--- a/backend/app/models/photos.py
+++ b/backend/app/models/photos.py
@@ -55,10 +55,10 @@ class Photo(Base):
user_notes = Column(Text)
rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
- is_picked = Column(Boolean, default=False)
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
- # concept). The DB column may still exist on legacy installs but is no
- # longer read or written.
+ # concept). is_picked was unified with active-heap membership — picking a
+ # photo just means adding it to the active heap. Both DB columns may still
+ # exist on legacy installs but are no longer read or written.
# Duplicate detection
is_duplicate = Column(Boolean, default=False)
diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py
index 7ce7fa5..49f02c4 100644
--- a/backend/app/routers/heaps.py
+++ b/backend/app/routers/heaps.py
@@ -134,6 +134,18 @@ async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
return None
+@router.get("/{heap_id}/photo_ids")
+async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
+ """Return just the photo ids belonging to a heap. Used by the frontend
+ to maintain a fast client-side membership lookup for the active heap
+ (for the basket affordance on thumbnails) without fetching full photo
+ records."""
+ result = await db.execute(
+ select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
+ )
+ return [row[0] for row in result.all()]
+
+
@router.post("/{heap_id}/photos")
async def add_photos_to_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py
index bb17f15..8e4b464 100644
--- a/backend/app/routers/photos.py
+++ b/backend/app/routers/photos.py
@@ -33,7 +33,6 @@ async def list_photos(
rating_min: Optional[int] = Query(None, ge=0, le=5),
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
- is_picked: Optional[bool] = None,
is_discarded: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
@@ -90,10 +89,6 @@ async def list_photos(
else:
filters.append(Photo.color_label == color_label)
- # Flag filters
- if is_picked is not None:
- filters.append(Photo.is_picked == is_picked)
-
# Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_discarded == is_discarded)
@@ -468,10 +463,6 @@ async def bulk_action(
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
- elif action.action == 'pick':
- for photo in photos:
- photo.is_picked = True
- photo.is_discarded = False
else:
raise HTTPException(status_code=400, detail="Invalid action")
diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py
index 70a703b..4a3da4b 100644
--- a/backend/app/schemas/photos.py
+++ b/backend/app/schemas/photos.py
@@ -19,7 +19,6 @@ class PhotoBase(BaseModel):
user_notes: Optional[str] = None
rating: int = 0
color_label: Optional[str] = None
- is_picked: bool = False
class PhotoResponse(PhotoBase):
"""Photo response schema"""
@@ -51,7 +50,6 @@ class PhotoUpdate(BaseModel):
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None
- is_picked: Optional[bool] = None
is_discarded: Optional[bool] = None
taken_at: Optional[datetime] = None
@@ -66,5 +64,5 @@ class PhotoListResponse(BaseModel):
class BulkAction(BaseModel):
"""Bulk action on photos"""
ids: List[str]
- action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
+ action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
\ No newline at end of file
diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx
index 90a98f5..aac27e6 100644
--- a/frontend/src/components/KeyboardHints.tsx
+++ b/frontend/src/components/KeyboardHints.tsx
@@ -11,9 +11,8 @@ export function KeyboardHints() {
const hints = selectedCount > 0
? [
{ key: '1-5', action: 'Rate' },
- { key: 'P', action: 'Pick' },
+ { key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' },
- { key: 'T', action: 'Add to heap' },
{ key: 'Space', action: 'Preview' },
{ key: 'Esc', action: 'Deselect' },
]
diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx
index 1741e70..32a9454 100644
--- a/frontend/src/components/filter/FilterBar.tsx
+++ b/frontend/src/components/filter/FilterBar.tsx
@@ -25,9 +25,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
{ value: 'any', label: 'Any' },
- { value: 'picked', label: 'Picked' },
{ value: 'discarded', label: 'Discarded' },
- { value: 'unflagged', label: 'Unflagged' },
]
export function FilterBar() {
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 84ec8ef..67e53fa 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -1,17 +1,16 @@
import { useState } from 'react'
-import {
+import {
ChevronRight,
ChevronDown,
Folder,
Image,
Calendar,
Star,
- Flag,
Trash2,
Plus,
MoreHorizontal,
HardDrive,
- RefreshCw
+ RefreshCw,
} from 'lucide-react'
import clsx from 'clsx'
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
@@ -52,10 +51,6 @@ export function LeftSidebar() {
clearAllFilters()
setRatingMin(1)
break
- case 'flagged':
- clearAllFilters()
- setFlag('picked')
- break
case 'discarded':
clearAllFilters()
setFlag('discarded')
@@ -138,7 +133,6 @@ export function LeftSidebar() {
{ id: 'all-photos', label: 'All Photos', icon: , count: 0 },
{ id: 'by-date', label: 'By Date', icon: },
{ id: 'rated', label: 'Rated', icon: , count: 0 },
- { id: 'flagged', label: 'Flagged', icon: , count: 0 },
{ id: 'discarded', label: 'Discarded', icon: , count: 0 },
],
},
diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx
index 79e0867..17c9bdc 100644
--- a/frontend/src/components/layout/RightSidebar.tsx
+++ b/frontend/src/components/layout/RightSidebar.tsx
@@ -8,14 +8,16 @@ import {
Info,
ChevronDown,
ChevronRight,
- Check,
+ ShoppingBasket,
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
-import { photos as photosApi } from '../../services/api'
+import { photos as photosApi, heaps as heapsApi } from '../../services/api'
+import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
+import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
interface PhotoDetails {
id: string
@@ -26,7 +28,6 @@ interface PhotoDetails {
file_size: number | null
taken_at: string | null
rating: number
- is_picked: boolean
is_discarded: boolean
user_title: string | null
user_notes: string | null
@@ -122,7 +123,6 @@ export function RightSidebar() {
const updateMutation = useMutation({
mutationFn: (data: {
rating?: number
- is_picked?: boolean
is_discarded?: boolean
user_title?: string | null
user_notes?: string | null
@@ -134,6 +134,29 @@ export function RightSidebar() {
},
})
+ // Membership in the active heap (for the Pick toggle button).
+ const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
+ const isInActiveHeap =
+ !!activePhotoId && activeHeapMembers.has(activePhotoId)
+
+ const heapMutation = useMutation({
+ mutationFn: ({ remove }: { remove: boolean }) => {
+ if (!activeHeap || !activePhotoId) return Promise.resolve(null)
+ return remove
+ ? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
+ : heapsApi.addPhotos(activeHeap.id, [activePhotoId])
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
+ if (activeHeap) {
+ queryClient.invalidateQueries({
+ queryKey: ['heap-photo-ids', activeHeap.id],
+ })
+ }
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ },
+ })
+
// Local drafts for the editable text fields. These mirror the server value
// but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches.
@@ -178,7 +201,6 @@ export function RightSidebar() {
const multipleSelected = selectedPhotos.length > 1
const rating = photo?.rating ?? 0
- const isPicked = photo?.is_picked ?? false
const isDiscarded = photo?.is_discarded ?? false
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
@@ -299,28 +321,28 @@ export function RightSidebar() {