feat: heaps end-to-end with active heap and T shortcut

Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.

Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
  via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
  existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
  heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
  IN-subquery against heap_photos (it was a declared param but had
  no filter logic).

Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
  another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
  stub. Shows the basket icon, photo counts, lets you create heaps
  inline, click to filter the timeline, set active via the target
  icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
  always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.

T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
  from the photo store at fire time. Adds the selected photos (or the
  active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
  - "Added to {heap}: N photos (M already present)" on success
  - "No active heap" hint when none is set
  - "Nothing selected" hint when there's no selection or active photo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:06:52 +02:00
parent 322969c938
commit ebae775f3f
12 changed files with 537 additions and 31 deletions

View File

@@ -55,6 +55,9 @@ function parseUrl(): Partial<FilterState> {
out.flag = flag as FlagFilter
}
const heapId = sp.get('heap_id')
if (heapId) out.heapId = heapId
return out
}
@@ -67,6 +70,7 @@ function writeUrl(f: FilterState) {
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
if (f.colorLabel) sp.set('color_label', f.colorLabel)
if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId)
const search = sp.toString()
const next = search ? `?${search}` : window.location.pathname

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { heaps as heapsApi, type Heap } from '../services/api'
export const HEAPS_QUERY_KEY = ['heaps'] as const
export function useHeapsQuery() {
return useQuery<Heap[]>({
queryKey: HEAPS_QUERY_KEY,
queryFn: heapsApi.list,
staleTime: 30_000,
})
}

View File

@@ -2,7 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
import { photos as photosApi } from '../services/api'
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -60,6 +62,52 @@ 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),
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' : ''}`)
}
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) => toast.error('Failed to add to heap', e.message || 'Unknown error'),
})
const addSelectionToActiveHeap = () => {
const state = usePhotoStore.getState()
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')
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')
return
}
addToHeapMutation.mutate({ heapId: active.id, photoIds: ids })
}
// Toggle sidebars
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
@@ -133,4 +181,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
},
HK_OPTS
)
// T: add selection (or active photo) to the active heap.
useHotkeys('t', addSelectionToActiveHeap, HK_OPTS)
}

View File

@@ -18,6 +18,7 @@ export function usePhotosQuery() {
const ratingMin = useFilterStore((s) => s.ratingMin)
const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
const filterParams = useMemo(
() =>
@@ -29,8 +30,9 @@ export function usePhotosQuery() {
ratingMin,
colorLabel,
flag,
heapId,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId]
)
return useQuery({