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>
322 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|