Files
mule-image/frontend/src/components/heaps/HeapsPanel.tsx
dtoro b4a2241bd9 feat: per-section filter memory
Filters were global — switching from "Discarded" to a folder kept the
discarded flag, switching from a heap to All Photos kept the heap
filter, etc. Confusing because the user couldn't tell what state any
section would be in until they got there.

Now each "section" remembers its own filter state independently. The
in-memory map is keyed by section id ('all-photos', 'rated',
'discarded', 'duplicates', 'tags', 'folder-{id}', 'heap-{id}'), and
navigating saves the current section's state under its id and
restores the destination's. Sections you've never visited start with
their intrinsic preset on top of INITIAL_FILTERS.

filterStore additions
- currentSection: string (default 'all-photos')
- sectionFilters: Record<sectionId, FilterState> — in-memory snapshots
- sectionPresets: Record<sectionId, Partial<FilterState>> — the
  intrinsic filter that defines each section, used by clearAll
- navigateToSection(id, presetOverrides):
    1. snapshot the current FilterState slice into sectionFilters[
       currentSection]
    2. record presetOverrides in sectionPresets[id]
    3. set currentSection = id
    4. load sectionFilters[id] if a saved snapshot exists, otherwise
       apply presetOverrides on top of INITIAL_FILTERS
- clearAll: now resets the CURRENT section to its preset rather than
  jumping to all-photos. The user explicitly clicks All Photos to
  navigate.
- snapshotFilters() helper extracts the FilterState slice cleanly so
  control fields (filterBarOpen, the maps themselves) don't leak
  into per-section state.

URL sync
- writeUrl serialises currentSection as ?section=… (omitted for the
  default 'all-photos').
- parseUrl reads it back into currentSection on hydrate. Per-section
  memory is in-memory only; reload restores the current view but
  not the other sections' saved states (acceptable for MVP).

LeftSidebar
- applyLibraryNode now dispatches navigateToSection per node, with
  the appropriate preset:
    all-photos → {}
    rated      → { ratingMin: 1 }
    discarded  → { flag: 'discarded' }
    duplicates → { duplicates: true }
    tags       → { groupBy: 'tag' }
    folder-X   → { folderId: X }
- isItemActive collapses to a single check against currentSection
  for both library nodes and folder rows. Dropped the old
  selectedItem local state and the per-field active probes; they
  were doing the same job in a more fragile way.

HeapsPanel
- Heap row click → navigateToSection(`heap-${id}`, { heapId: id })
- isFiltered uses currentSection instead of filterStore.heapId
- Deleting the currently-viewed heap navigates back to all-photos
  via navigateToSection (was setFilterHeapId(null), which now lives
  in the section model).

User flow:
1. Click Discarded → seeing discarded photos.
2. Open FilterBar, set Rating ≥ 3 — discarded section now has rating.
3. Click Library "Library" folder → no rating filter, just library
   contents.
4. Open FilterBar, set media type Photo only — folder section now
   has that.
5. Click Discarded again → restored to discarded + rating ≥ 3.
6. Click Library folder again → restored to library + photo only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:07:16 +02:00

322 lines
12 KiB
TypeScript

import { useState } from 'react'
import {
ShoppingBasket,
Plus,
Target,
X,
ChevronDown,
ChevronRight,
FolderOutput,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
* basket icon, lets the user create a new heap, click one to filter the
* timeline to its contents, set one as the "active" target for the T
* shortcut, and delete heaps.
*
* Heap state:
* - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row)
*/
export function HeapsPanel() {
const { data: heaps = [] } = useHeapsQuery()
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
}
const createMutation = useMutation({
mutationFn: (name: string) => heapsApi.create(name),
onSuccess: () => {
invalidate()
setNewName('')
setCreating(false)
},
onError: (e: any) =>
toast.error('Failed to create heap', e.message || 'Unknown error'),
})
const setActiveMutation = useMutation({
mutationFn: (heapId: string) =>
heapsApi.update(heapId, { is_active: true }),
onSuccess: (heap) => {
invalidate()
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
},
onError: (e: any) =>
toast.error('Failed to set active', e.message || 'Unknown error'),
})
const deleteMutation = useMutation({
mutationFn: (heapId: string) => heapsApi.delete(heapId),
onSuccess: (_, heapId) => {
invalidate()
// If we were viewing this heap, snap back to all-photos.
if (currentSection === `heap-${heapId}`) {
navigateToSection('all-photos', {})
}
},
onError: (e: any) =>
toast.error('Failed to delete heap', e.message || 'Unknown error'),
})
// Drop handler: add the dragged photos to the target heap. Optimistically
// updates the membership cache so the basket affordance flips immediately,
// mirroring the keyboard P-toggle pattern.
const dropMutation = useMutation({
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
heapsApi.addPhotos(heapId, photoIds),
onMutate: ({ heapId, photoIds }) => {
const key = ['heap-photo-ids', heapId] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
photoIds.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (e: any, vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
}
toast.error('Failed to add to heap', e.message || 'Unknown error')
},
onSuccess: (data, vars) => {
const heap = heaps.find((h) => h.id === vars.heapId)
const heapName = heap?.name ?? 'heap'
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)` : ''}`
)
} else if (already > 0) {
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
}
},
onSettled: (_d, _e, vars) => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleCreate = () => {
const name = newName.trim()
if (!name) return
createMutation.mutate(name)
}
return (
<div>
{/* Section header */}
<div
className="group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm text-text hover:bg-surface-2"
onClick={() => setExpanded((v) => !v)}
>
<button className="rounded p-0.5 hover:bg-surface-offset">
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
<ShoppingBasket className="h-4 w-4 text-text-muted" />
<span className="flex-1 truncate">Heaps</span>
<button
onClick={(e) => {
e.stopPropagation()
setCreating(true)
setExpanded(true)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="New heap"
>
<Plus className="h-3 w-3" />
</button>
</div>
{expanded && (
<div>
{/* Inline create form */}
{creating && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: '32px' }}
>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') {
setCreating(false)
setNewName('')
}
}}
placeholder="Heap name"
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/>
<button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-xs text-white hover:bg-primary/80 disabled:opacity-50"
>
Add
</button>
</div>
)}
{heaps.length === 0 && !creating && (
<div
className="px-2 py-1 text-xs text-text-faint"
style={{ paddingLeft: '32px' }}
>
No heaps yet
</div>
)}
{heaps.map((heap) => {
const isFiltered = currentSection === `heap-${heap.id}`
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
return (
<div
key={heap.id}
className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '32px' }}
onClick={() =>
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
}
}}
onDragLeave={(e) => {
// Only clear if we're actually leaving this row, not just
// moving over a child element.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === heap.id) setDropTargetId(null)
}
}}
onDrop={(e) => {
e.preventDefault()
setDropTargetId(null)
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
if (!raw) return
try {
const ids = JSON.parse(raw) as string[]
if (Array.isArray(ids) && ids.length > 0) {
dropMutation.mutate({ heapId: heap.id, photoIds: ids })
}
} catch {
// Bad payload — ignore.
}
}}
>
<ShoppingBasket
className={clsx(
'h-4 w-4 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
<span
className={clsx(
'flex-1 truncate',
isActive && 'font-semibold'
)}
title={heap.name}
>
{heap.name}
</span>
{isActive && (
<Target
className="h-3 w-3 text-primary"
aria-label="Active heap (T target)"
/>
)}
{heap.photo_count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{heap.photo_count}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation()
if (!isActive) setActiveMutation.mutate(heap.id)
}}
className={clsx(
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
isActive
? 'invisible'
: 'invisible text-text-muted group-hover:visible'
)}
title="Set as active heap (T target)"
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
deleteMutation.mutate(heap.id)
}
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-reject group-hover:visible"
title="Delete heap"
>
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
</div>
)
}