From 351ccd7bb446a874eae2eb26c04a93a639b1dceb Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 23:20:31 +0200 Subject: [PATCH] refactor: unify Pick with active heap membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pick and add-to-heap were two ways of saying "I want to keep this one". Merging them: P now toggles the selection's membership in the active heap. The is_picked flag goes away (orphaned in the DB the same way is_trashed was). Backend - Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and from the photos list filter param. - Drop the is_picked Column from the Photo model (DB column stays on legacy installs but is no longer read or written). - Drop the bulk action 'pick' branch. - New GET /heaps/{id}/photo_ids returns just the flat string list. Used by the frontend for fast client-side membership lookups without fetching full photo records. Frontend - New hooks/useActiveHeapMembersQuery.ts → returns { activeHeap, memberIds: Set }. Subscribes once at the Timeline level and passes a derived isInActiveHeap bool down to each PhotoThumbnail (avoids hundreds of thumbnails subscribing to the same query). - PhotoThumbnail: replaces the old check-icon Pick affordance with a clear basket badge in the bottom-right corner — a small filled pick-colour pill containing a ShoppingBasket icon — visible only when the photo belongs to the active heap. - P shortcut (useKeyboardShortcuts) now toggles membership: if every selected photo is already a member, it removes them; otherwise it adds the missing ones. T binding removed (P fully replaces it). - RightSidebar Pick button is now a Pick / Picked toggle bound to the active heap. Disabled with a hint when no heap is active. Shows the heap name in its title attr. - filterStore drops 'picked' and 'unflagged' from FlagFilter. FilterBar's flag dropdown is now just Any / Discarded. - LeftSidebar drops the "Flagged" virtual node (it just set flag=picked, which no longer exists). - KeyboardHints: P → "Pick → heap". - Photo TS type drops is_picked. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/models/photos.py | 6 +- backend/app/routers/heaps.py | 12 +++ backend/app/routers/photos.py | 9 -- backend/app/schemas/photos.py | 4 +- frontend/src/components/KeyboardHints.tsx | 3 +- frontend/src/components/filter/FilterBar.tsx | 2 - .../src/components/layout/LeftSidebar.tsx | 10 +- .../src/components/layout/RightSidebar.tsx | 60 +++++++---- .../components/timeline/PhotoThumbnail.tsx | 24 ++++- frontend/src/components/timeline/Timeline.tsx | 7 ++ .../src/hooks/useActiveHeapMembersQuery.ts | 39 +++++++ frontend/src/hooks/useFilterUrlSync.ts | 2 +- frontend/src/hooks/useKeyboardShortcuts.ts | 102 ++++++++++-------- frontend/src/services/api.ts | 7 ++ frontend/src/store/filterStore.ts | 6 +- frontend/src/types/photo.ts | 1 - 16 files changed, 192 insertions(+), 102 deletions(-) create mode 100644 frontend/src/hooks/useActiveHeapMembersQuery.ts 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() {