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:
39
frontend/src/hooks/useActiveHeapMembersQuery.ts
Normal file
39
frontend/src/hooks/useActiveHeapMembersQuery.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useHeapsQuery } from './useHeapsQuery'
|
||||
import { heaps as heapsApi } from '../services/api'
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||
|
||||
/**
|
||||
* Returns the photo ids that belong to the active heap as a Set, plus the
|
||||
* active heap itself. Used by PhotoThumbnail to render the basket affordance
|
||||
* and by the P shortcut to decide between add vs remove.
|
||||
*
|
||||
* If no heap is active, the Set is empty (and shared across renders).
|
||||
*/
|
||||
export function useActiveHeapMembers(): {
|
||||
activeHeap: ReturnType<typeof useHeapsQuery>['data'] extends (infer T)[] | undefined
|
||||
? T | null
|
||||
: never
|
||||
memberIds: ReadonlySet<string>
|
||||
} {
|
||||
const { data: heaps } = useHeapsQuery()
|
||||
const activeHeap = heaps?.find((h) => h.is_active) ?? null
|
||||
|
||||
const { data: ids } = useQuery({
|
||||
queryKey: ['heap-photo-ids', activeHeap?.id],
|
||||
queryFn: () => heapsApi.photoIds(activeHeap!.id),
|
||||
enabled: !!activeHeap,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const memberIds = useMemo(
|
||||
() => (ids ? new Set(ids) : EMPTY_SET),
|
||||
[ids]
|
||||
)
|
||||
|
||||
return { activeHeap: activeHeap as any, memberIds }
|
||||
}
|
||||
|
||||
export const ACTIVE_HEAP_MEMBERS_QUERY_KEY_PREFIX = ['heap-photo-ids'] as const
|
||||
@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
|
||||
'blue',
|
||||
'purple',
|
||||
]
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
|
||||
|
||||
function parseUrl(): Partial<FilterState> {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -15,7 +15,6 @@ interface KeyboardShortcutsProps {
|
||||
|
||||
interface PhotoUpdate {
|
||||
rating?: number
|
||||
is_picked?: boolean
|
||||
is_discarded?: boolean
|
||||
color_label?: string | null
|
||||
}
|
||||
@@ -62,50 +61,77 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
updateMutation.mutate({ id, data })
|
||||
}
|
||||
|
||||
// T key: add the current selection to the active heap. If no heap is
|
||||
// active or no photos are selected, it's a no-op with a toast hint.
|
||||
const addToHeapMutation = useMutation({
|
||||
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
|
||||
heapsApi.addPhotos(heapId, photoIds),
|
||||
// P key (Pick): toggle the current selection's membership in the active
|
||||
// heap. If every selected photo is already a member, remove them; otherwise
|
||||
// add the missing ones. No active heap → toast hint.
|
||||
const heapMutation = useMutation({
|
||||
mutationFn: ({
|
||||
heapId,
|
||||
photoIds,
|
||||
remove,
|
||||
}: {
|
||||
heapId: string
|
||||
photoIds: string[]
|
||||
remove: boolean
|
||||
}) =>
|
||||
remove
|
||||
? heapsApi.removePhotos(heapId, photoIds)
|
||||
: heapsApi.addPhotos(heapId, photoIds),
|
||||
onSuccess: (data, vars) => {
|
||||
const added = data?.added ?? 0
|
||||
const already = data?.already_present ?? 0
|
||||
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
||||
(h) => h.id === vars.heapId
|
||||
)
|
||||
const heapName = heap?.name ?? 'heap'
|
||||
if (added > 0) {
|
||||
toast.success(
|
||||
`Added to ${heapName}`,
|
||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||
)
|
||||
} else if (already > 0) {
|
||||
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
|
||||
if (vars.remove) {
|
||||
const removed = data?.removed ?? 0
|
||||
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
|
||||
} else {
|
||||
const added = data?.added ?? 0
|
||||
const already = data?.already_present ?? 0
|
||||
if (added > 0) {
|
||||
toast.success(
|
||||
`Added to ${heapName}`,
|
||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
onError: (e: any) => toast.error('Failed to add to heap', e.message || 'Unknown error'),
|
||||
onError: (e: any) => toast.error('Heap update failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const addSelectionToActiveHeap = () => {
|
||||
const togglePickOnSelection = () => {
|
||||
const state = usePhotoStore.getState()
|
||||
const ids = state.selectedPhotos.length > 0
|
||||
? state.selectedPhotos
|
||||
: state.activePhotoId
|
||||
? [state.activePhotoId]
|
||||
: []
|
||||
const ids =
|
||||
state.selectedPhotos.length > 0
|
||||
? state.selectedPhotos
|
||||
: state.activePhotoId
|
||||
? [state.activePhotoId]
|
||||
: []
|
||||
if (ids.length === 0) {
|
||||
toast.info('Nothing selected', 'Select photos first, then press T')
|
||||
toast.info('Nothing selected', 'Select photos first, then press P')
|
||||
return
|
||||
}
|
||||
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
||||
const active = heapsList.find((h) => h.is_active)
|
||||
if (!active) {
|
||||
toast.info('No active heap', 'Click the target icon next to a heap to set it as active')
|
||||
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
||||
return
|
||||
}
|
||||
addToHeapMutation.mutate({ heapId: active.id, photoIds: ids })
|
||||
// Determine direction: if every selected photo is already a member, this
|
||||
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
|
||||
// Lightroom's flag-toggle works.
|
||||
const memberIds =
|
||||
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
|
||||
const memberSet = new Set(memberIds)
|
||||
const allMembers = ids.every((id) => memberSet.has(id))
|
||||
heapMutation.mutate({
|
||||
heapId: active.id,
|
||||
photoIds: ids,
|
||||
remove: allMembers,
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle sidebars
|
||||
@@ -151,26 +177,14 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
|
||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||
|
||||
// Pick / discard / unflag. Discard is a soft flag that hides the photo
|
||||
// from the default timeline view; restore via the Discarded view (or the
|
||||
// U shortcut).
|
||||
useHotkeys(
|
||||
'p',
|
||||
() => updateActive({ is_picked: true, is_discarded: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
||||
// adding it to the heap you set as active. Toggling on already-picked
|
||||
// photos removes them from the heap.
|
||||
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
||||
|
||||
useHotkeys(
|
||||
'x',
|
||||
() => updateActive({ is_discarded: true, is_picked: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
||||
|
||||
useHotkeys(
|
||||
'u',
|
||||
() => updateActive({ is_picked: false, is_discarded: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
||||
|
||||
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
||||
useHotkeys(
|
||||
@@ -182,6 +196,4 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
// T: add selection (or active photo) to the active heap.
|
||||
useHotkeys('t', addSelectionToActiveHeap, HK_OPTS)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user