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

@@ -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' },
]

View File

@@ -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() {

View File

@@ -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: <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: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},

View File

@@ -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() {
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() =>
updateMutation.mutate({
is_picked: !isPicked,
is_discarded: false,
})
}
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isPicked
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap
? 'bg-pick/20 text-pick'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
activeHeap
? isInActiveHeap
? `Remove from "${activeHeap.name}"`
: `Add to "${activeHeap.name}"`
: 'Set an active heap first'
}
>
<Check className="h-3 w-3" />
Pick
<ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Picked' : 'Pick'}
</button>
<button
onClick={() =>
updateMutation.mutate({
is_discarded: !isDiscarded,
is_picked: false,
})
updateMutation.mutate({ is_discarded: !isDiscarded })
}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',

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)