refactor: unify Pick with active heap membership

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<string> }. 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:20:31 +02:00
parent 02fb1cd508
commit 351ccd7bb4
16 changed files with 192 additions and 102 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, Check, Trash2, RefreshCw } from 'lucide-react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -12,11 +12,20 @@ interface PhotoThumbnailProps {
photo: Photo
size: number
isSelected: boolean
/** True when the photo belongs to the currently active heap. */
isInActiveHeap?: boolean
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
}
export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick }: PhotoThumbnailProps) {
export function PhotoThumbnail({
photo,
size,
isSelected,
isInActiveHeap = false,
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [retryCount, setRetryCount] = useState(0)
@@ -167,9 +176,14 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
)}
{/* Flag Indicators */}
<div className="absolute bottom-1 right-1">
{photo.is_picked && (
<Check className="h-4 w-4 text-pick" />
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
title="In active heap"
>
<ShoppingBasket className="h-3 w-3" />
</div>
)}
{photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" />

View File

@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import type { Photo } from '../../types/photo'
export function Timeline() {
@@ -48,6 +49,11 @@ export function Timeline() {
// they share one cache entry, regardless of filter state.
const { data: photos = [], isLoading } = usePhotosQuery()
// Membership in the active heap (for the basket affordance). Subscribed
// once at this level so we don't have hundreds of thumbnails each
// subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Group photos into rows for grid layout
const rows = useMemo(() => {
const result: Photo[][] = []
@@ -212,6 +218,7 @@ export function Timeline() {
photo={photo}
size={thumbnailSize}
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)