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>
234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import {
|
|
Search,
|
|
Grid,
|
|
List,
|
|
SlidersHorizontal,
|
|
FolderOpen,
|
|
Upload,
|
|
Settings,
|
|
Menu,
|
|
Trash2,
|
|
X,
|
|
ShoppingBasket,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { usePhotoStore } from '../../store/photoStore'
|
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
|
import { photos } from '../../services/api'
|
|
import { toast } from '../ToastContainer'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
|
import muliLogo from '../../assets/muli-logo.png'
|
|
|
|
const SEARCH_DEBOUNCE_MS = 300
|
|
|
|
export function TopBar() {
|
|
// Filter store is the source of truth for search; the input has a local
|
|
// mirror so typing stays responsive while we debounce store updates.
|
|
const storeQ = useFilterStore((s) => s.q)
|
|
const setStoreQ = useFilterStore((s) => s.setQ)
|
|
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
|
|
const toggleFilterBar = useFilterStore((s) => s.toggleFilterBar)
|
|
const filterState = useFilterStore()
|
|
const filtersActive = hasActiveFilters(filterState) || filterBarOpen
|
|
|
|
const [searchQuery, setSearchQuery] = useState(storeQ)
|
|
|
|
// Keep local input in sync if the store is changed externally (URL hydrate,
|
|
// active-chip removal, clear-all).
|
|
useEffect(() => {
|
|
setSearchQuery(storeQ)
|
|
}, [storeQ])
|
|
|
|
// Debounce local input -> store.
|
|
const debounceRef = useRef<number | null>(null)
|
|
useEffect(() => {
|
|
if (searchQuery === storeQ) return
|
|
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
|
debounceRef.current = window.setTimeout(() => {
|
|
setStoreQ(searchQuery)
|
|
}, SEARCH_DEBOUNCE_MS)
|
|
return () => {
|
|
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
|
}
|
|
}, [searchQuery, storeQ, setStoreQ])
|
|
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
|
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
|
const clearSelection = usePhotoStore((state) => state.clearSelection)
|
|
const selectedCount = selectedPhotos.length
|
|
const queryClient = useQueryClient()
|
|
|
|
// Currently active heap (for the T shortcut). Shown as a pill so the user
|
|
// always knows where their next T-press will land.
|
|
const { data: heapsList = [] } = useHeapsQuery()
|
|
const activeHeap = heapsList.find((h) => h.is_active)
|
|
|
|
// Mutation for discarding selected photos
|
|
const discardPhotosMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await photos.bulkUpdate(selectedPhotos, { discard: true })
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`)
|
|
clearSelection()
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error('Failed to discard', error.message || 'An error occurred')
|
|
},
|
|
})
|
|
|
|
return (
|
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
|
{/* Left Section - Menu and App Name */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
className="group relative rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Toggle sidebar (Tab)"
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
|
Tab
|
|
</kbd>
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
|
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
|
</div>
|
|
{activeHeap && (
|
|
<span
|
|
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
|
|
title="Active heap — press T to add the selected photos here"
|
|
>
|
|
<ShoppingBasket className="h-3 w-3" />
|
|
{activeHeap.name}
|
|
</span>
|
|
)}
|
|
{selectedCount > 0 && (
|
|
<>
|
|
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
|
{selectedCount} selected
|
|
</span>
|
|
<button
|
|
onClick={() => discardPhotosMutation.mutate()}
|
|
disabled={discardPhotosMutation.isPending}
|
|
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
|
title="Discard"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Discard
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Center Section - Search */}
|
|
<div className="flex max-w-xl flex-1 items-center px-8">
|
|
<div className="relative w-full">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
|
<input
|
|
id="topbar-search"
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
setSearchQuery('')
|
|
setStoreQ('')
|
|
e.currentTarget.blur()
|
|
}
|
|
}}
|
|
placeholder="Search photos..."
|
|
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-9 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('')
|
|
setStoreQ('')
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Clear search"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Section - View Controls and Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{/* View Mode Toggle */}
|
|
<div className="flex rounded-md border border-border">
|
|
<button
|
|
className={clsx(
|
|
'rounded-l-md px-2 py-1',
|
|
viewMode === 'grid'
|
|
? 'bg-primary text-white'
|
|
: 'bg-surface text-text-muted hover:bg-surface-2'
|
|
)}
|
|
onClick={() => setViewMode('grid')}
|
|
title="Grid view"
|
|
>
|
|
<Grid className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
className={clsx(
|
|
'rounded-r-md px-2 py-1',
|
|
viewMode === 'list'
|
|
? 'bg-primary text-white'
|
|
: 'bg-surface text-text-muted hover:bg-surface-2'
|
|
)}
|
|
onClick={() => setViewMode('list')}
|
|
title="List view"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Button */}
|
|
<button
|
|
onClick={toggleFilterBar}
|
|
className={clsx(
|
|
'group relative rounded p-1.5 transition-colors',
|
|
filtersActive
|
|
? 'bg-primary/20 text-primary'
|
|
: 'text-text-muted hover:bg-surface-2 hover:text-text'
|
|
)}
|
|
title="Toggle filters (\\)"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
|
\
|
|
</kbd>
|
|
</button>
|
|
|
|
<div className="mx-1 h-6 w-px bg-border" />
|
|
|
|
{/* Action Buttons */}
|
|
<button
|
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Add folder"
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
</button>
|
|
|
|
<button
|
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Import photos"
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
</button>
|
|
|
|
<button
|
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Settings"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
)
|
|
} |