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:
@@ -13,6 +13,7 @@ export function KeyboardHints() {
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'P', action: 'Pick' },
|
||||
{ key: 'X', action: 'Discard' },
|
||||
{ key: 'T', action: 'Add to heap' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
]
|
||||
|
||||
224
frontend/src/components/heaps/HeapsPanel.tsx
Normal file
224
frontend/src/components/heaps/HeapsPanel.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ShoppingBasket,
|
||||
Plus,
|
||||
Target,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} 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 } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
/**
|
||||
* 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 filterHeapId = useFilterStore((s) => s.heapId)
|
||||
const setFilterHeapId = useFilterStore((s) => s.setHeapId)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
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 filtering by this heap, clear the filter
|
||||
if (filterHeapId === heapId) setFilterHeapId(null)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
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 = filterHeapId === heap.id
|
||||
const isActive = heap.is_active
|
||||
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'
|
||||
)}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
onClick={() => setFilterHeapId(heap.id)}
|
||||
>
|
||||
<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()
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { sourceFolders, library } from '../../services/api'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||
|
||||
interface TreeItem {
|
||||
id: string
|
||||
@@ -153,12 +154,6 @@ export function LeftSidebar() {
|
||||
type: 'folder',
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'heaps',
|
||||
label: 'Heaps',
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
children: [], // Will be populated from API
|
||||
},
|
||||
]
|
||||
|
||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||
@@ -257,6 +252,7 @@ export function LeftSidebar() {
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
<HeapsPanel />
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Menu,
|
||||
Trash2,
|
||||
X,
|
||||
ShoppingBasket,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -57,6 +59,11 @@ export function TopBar() {
|
||||
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({
|
||||
@@ -90,6 +97,15 @@ export function TopBar() {
|
||||
<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">
|
||||
|
||||
@@ -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
|
||||
|
||||
12
frontend/src/hooks/useHeapsQuery.ts
Normal file
12
frontend/src/hooks/useHeapsQuery.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -112,30 +112,49 @@ export const library = {
|
||||
}
|
||||
|
||||
// Heaps API
|
||||
export interface Heap {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const heaps = {
|
||||
list: async () => {
|
||||
list: async (): Promise<Heap[]> => {
|
||||
const response = await api.get('/heaps')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (name: string, description?: string) => {
|
||||
const response = await api.post('/heaps', {
|
||||
name,
|
||||
description,
|
||||
})
|
||||
create: async (name: string): Promise<Heap> => {
|
||||
const response = await api.post('/heaps', { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (heapId: string, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
}) => {
|
||||
update: async (
|
||||
heapId: string,
|
||||
data: { name?: string; is_active?: boolean }
|
||||
): Promise<Heap> => {
|
||||
const response = await api.patch(`/heaps/${heapId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (heapId: string) => {
|
||||
const response = await api.delete(`/heaps/${heapId}`)
|
||||
delete: async (heapId: string): Promise<void> => {
|
||||
await api.delete(`/heaps/${heapId}`)
|
||||
},
|
||||
|
||||
addPhotos: async (heapId: string, photoIds: string[]) => {
|
||||
const response = await api.post(`/heaps/${heapId}/photos`, {
|
||||
photo_ids: photoIds,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
removePhotos: async (heapId: string, photoIds: string[]) => {
|
||||
const response = await api.delete(`/heaps/${heapId}/photos`, {
|
||||
data: { photo_ids: photoIds },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface FilterState {
|
||||
ratingMin: number // 0-5; 0 means no filter
|
||||
colorLabel: ColorLabel | null
|
||||
flag: FlagFilter
|
||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||
* on the heap store — that's the target for the T shortcut. */
|
||||
heapId: string | null
|
||||
}
|
||||
|
||||
interface FilterStore extends FilterState {
|
||||
@@ -24,6 +27,7 @@ interface FilterStore extends FilterState {
|
||||
setRatingMin: (rating: number) => void
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
|
||||
setFilterBarOpen: (open: boolean) => void
|
||||
toggleFilterBar: () => void
|
||||
@@ -40,6 +44,7 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
ratingMin: 0,
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
}
|
||||
|
||||
export const useFilterStore = create<FilterStore>((set) => ({
|
||||
@@ -58,6 +63,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
setRatingMin: (ratingMin) => set({ ratingMin }),
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
setHeapId: (heapId) => set({ heapId }),
|
||||
|
||||
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
|
||||
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
|
||||
@@ -79,6 +85,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
||||
if (f.flag === 'picked') params.is_picked = 'true'
|
||||
else if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -91,6 +98,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
||||
f.mediaTypes.length > 0 ||
|
||||
f.ratingMin > 0 ||
|
||||
f.colorLabel !== null ||
|
||||
f.flag !== 'any'
|
||||
f.flag !== 'any' ||
|
||||
f.heapId !== null
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user