From ebae775f3f504e95a36f75d27d3dddf79b0787cc Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 23:06:52 +0200 Subject: [PATCH] feat: heaps end-to-end with active heap and T shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/routers/heaps.py | 182 +++++++++++++- backend/app/routers/photos.py | 11 +- frontend/src/components/KeyboardHints.tsx | 1 + frontend/src/components/heaps/HeapsPanel.tsx | 224 ++++++++++++++++++ .../src/components/layout/LeftSidebar.tsx | 8 +- frontend/src/components/layout/TopBar.tsx | 16 ++ frontend/src/hooks/useFilterUrlSync.ts | 4 + frontend/src/hooks/useHeapsQuery.ts | 12 + frontend/src/hooks/useKeyboardShortcuts.ts | 53 ++++- frontend/src/hooks/usePhotosQuery.ts | 4 +- frontend/src/services/api.ts | 43 +++- frontend/src/store/filterStore.ts | 10 +- 12 files changed, 537 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/heaps/HeapsPanel.tsx create mode 100644 frontend/src/hooks/useHeapsQuery.ts diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index 85e10cc..7ce7fa5 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -1,27 +1,191 @@ """ Heaps API router """ +from typing import Optional from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from pydantic import BaseModel +from sqlalchemy import select, func, update, insert, delete from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Heap +from app.models.heaps import heap_photos router = APIRouter() + +# ── Schemas ─────────────────────────────────────────────────────────────── + +class HeapCreate(BaseModel): + name: str + + +class HeapUpdate(BaseModel): + name: Optional[str] = None + is_active: Optional[bool] = None + + +class HeapPhotosBody(BaseModel): + photo_ids: list[str] + + +# ── Endpoints ───────────────────────────────────────────────────────────── + @router.get("") async def list_heaps(db: AsyncSession = Depends(get_db)): - """List all heaps""" - result = await db.execute(select(Heap)) - heaps = result.scalars().all() - return heaps + """List all heaps with photo counts.""" + # LEFT JOIN heap_photos and group so we can return counts in one query. + count_subq = ( + select( + heap_photos.c.heap_id, + func.count(heap_photos.c.photo_id).label("photo_count"), + ) + .group_by(heap_photos.c.heap_id) + .subquery() + ) -@router.post("") -async def create_heap(name: str, db: AsyncSession = Depends(get_db)): - """Create a new heap""" + stmt = ( + select(Heap, count_subq.c.photo_count) + .outerjoin(count_subq, Heap.id == count_subq.c.heap_id) + .order_by(Heap.created_at.asc()) + ) + result = await db.execute(stmt) + rows = result.all() + + return [ + { + "id": h.id, + "name": h.name, + "is_active": bool(h.is_active), + "created_at": h.created_at, + "updated_at": h.updated_at, + "photo_count": int(count or 0), + } + for h, count in rows + ] + + +@router.post("", status_code=201) +async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)): + """Create a new heap.""" + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Heap name is required") heap = Heap(name=name) db.add(heap) await db.commit() await db.refresh(heap) - return heap \ No newline at end of file + return { + "id": heap.id, + "name": heap.name, + "is_active": bool(heap.is_active), + "created_at": heap.created_at, + "updated_at": heap.updated_at, + "photo_count": 0, + } + + +@router.patch("/{heap_id}") +async def update_heap( + heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db) +): + """Rename a heap and/or toggle active state. Setting is_active=true on + one heap deactivates all others (single-active invariant).""" + result = await db.execute(select(Heap).where(Heap.id == heap_id)) + heap = result.scalar_one_or_none() + if not heap: + raise HTTPException(status_code=404, detail="Heap not found") + + if body.name is not None: + name = body.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Heap name is required") + heap.name = name + + if body.is_active is not None: + if body.is_active: + # Clear active flag on all other heaps in one statement + await db.execute(update(Heap).values(is_active=False)) + heap.is_active = True + else: + heap.is_active = False + + await db.commit() + await db.refresh(heap) + return { + "id": heap.id, + "name": heap.name, + "is_active": bool(heap.is_active), + "created_at": heap.created_at, + "updated_at": heap.updated_at, + } + + +@router.delete("/{heap_id}", status_code=204) +async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)): + """Delete a heap. Photos themselves are unaffected — only the membership + rows in heap_photos cascade-delete.""" + result = await db.execute(select(Heap).where(Heap.id == heap_id)) + heap = result.scalar_one_or_none() + if not heap: + raise HTTPException(status_code=404, detail="Heap not found") + await db.delete(heap) + await db.commit() + return None + + +@router.post("/{heap_id}/photos") +async def add_photos_to_heap( + heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db) +): + """Add photos to a heap. Idempotent: re-adding existing members is a + no-op (handled by an INSERT OR IGNORE-style filter on duplicates).""" + result = await db.execute(select(Heap).where(Heap.id == heap_id)) + heap = result.scalar_one_or_none() + if not heap: + raise HTTPException(status_code=404, detail="Heap not found") + + if not body.photo_ids: + return {"status": "success", "added": 0} + + # Find which ids are already members so we don't violate the PK. + existing = await db.execute( + select(heap_photos.c.photo_id).where( + heap_photos.c.heap_id == heap_id, + heap_photos.c.photo_id.in_(body.photo_ids), + ) + ) + existing_ids = {row[0] for row in existing.all()} + new_ids = [pid for pid in body.photo_ids if pid not in existing_ids] + + if new_ids: + await db.execute( + insert(heap_photos), + [{"heap_id": heap_id, "photo_id": pid} for pid in new_ids], + ) + await db.commit() + + return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)} + + +@router.delete("/{heap_id}/photos") +async def remove_photos_from_heap( + heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db) +): + """Remove photos from a heap. Removing a non-member is a no-op.""" + result = await db.execute(select(Heap).where(Heap.id == heap_id)) + heap = result.scalar_one_or_none() + if not heap: + raise HTTPException(status_code=404, detail="Heap not found") + + if not body.photo_ids: + return {"status": "success", "removed": 0} + + res = await db.execute( + delete(heap_photos).where( + heap_photos.c.heap_id == heap_id, + heap_photos.c.photo_id.in_(body.photo_ids), + ) + ) + await db.commit() + return {"status": "success", "removed": res.rowcount or 0} diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 0b963b7..bb17f15 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) from app.database import get_db from app.models import Photo, Folder, Tag, PhotoTag +from app.models.heaps import heap_photos from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.config import settings @@ -95,7 +96,15 @@ async def list_photos( # Discard filter — defaults to hiding discarded photos filters.append(Photo.is_discarded == is_discarded) - + + # Heap membership filter — restrict to photos that belong to the heap. + if heap_id: + filters.append( + Photo.id.in_( + select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) + ) + ) + # Apply all filters if filters: query = query.where(and_(*filters)) diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index d182398..90a98f5 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -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' }, ] diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx new file mode 100644 index 0000000..664b575 --- /dev/null +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -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 ( +
+ {/* Section header */} +
setExpanded((v) => !v)} + > + + + Heaps + +
+ + {expanded && ( +
+ {/* Inline create form */} + {creating && ( +
+ 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" + /> + +
+ )} + + {heaps.length === 0 && !creating && ( +
+ No heaps yet +
+ )} + + {heaps.map((heap) => { + const isFiltered = filterHeapId === heap.id + const isActive = heap.is_active + return ( +
setFilterHeapId(heap.id)} + > + + + {heap.name} + + {isActive && ( + + )} + {heap.photo_count > 0 && ( + + {heap.photo_count} + + )} + + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 15eaa74..84ec8ef 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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: , - children: [], // Will be populated from API - }, ] const renderTreeItem = (item: TreeItem, depth: number = 0) => { @@ -257,6 +252,7 @@ export function LeftSidebar() { {/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))} +
{/* Bottom Actions */} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index fc73b25..41ddaac 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -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() { Mulita

Mulita

+ {activeHeap && ( + + + {activeHeap.name} + + )} {selectedCount > 0 && ( <> diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 978ac37..c165999 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -55,6 +55,9 @@ function parseUrl(): Partial { 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 diff --git a/frontend/src/hooks/useHeapsQuery.ts b/frontend/src/hooks/useHeapsQuery.ts new file mode 100644 index 0000000..18f76de --- /dev/null +++ b/frontend/src/hooks/useHeapsQuery.ts @@ -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({ + queryKey: HEAPS_QUERY_KEY, + queryFn: heapsApi.list, + staleTime: 30_000, + }) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index a043186..81c306b 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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(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(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) } diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 996a049..580d3cf 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -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({ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index be7d72e..89c8df7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 => { 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 => { + 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 => { 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 => { + 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 }, } diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index d295bdf..a447352 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -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((set) => ({ @@ -58,6 +63,7 @@ export const useFilterStore = create((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 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 ) }