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

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