feat: sorting + Google-Photos-style date-grouped timeline

Two related changes:

1. Sorting controls
   - filterStore gains sortBy (taken_at | added_at | filename | file_size
     | rating) and sortOrder (asc | desc), defaults taken_at desc.
   - filtersToParams sends sort + order to the backend list endpoint.
   - usePhotosQuery drops the hardcoded sort/order and reads from the
     store.
   - useFilterUrlSync round-trips ?sort= and ?order= so the choice
     persists in the URL.
   - FilterBar gets a Sort group with a field <select> and an asc/desc
     toggle button (ArrowDown / ArrowUp icons).

2. Date-grouped timeline (Google Photos style)
   - When sorted by a date field (taken_at or added_at), the Timeline
     now groups photos by month label ("April 2026") with a small
     header row between groups.
   - Refactored the virtualizer items from "rows of photos" to a flat
     mixed array of header | row items, with per-item heights via the
     virtualizer's estimateSize callback. Headers are 36px, photo rows
     are THUMBNAIL_SIZE + GAP.
   - buildItems() walks photos in order, breaks groups when the month
     label changes, and chunks each group into rows of `columns` cells.
     Photos with no taken_at fall back to "Unknown date".
   - For non-date sorts (filename / file_size / rating) the timeline
     reverts to a single un-headered stream — grouping by month
     wouldn't be meaningful.
   - Range selection and arrow-key nav still operate on the flat
     photos array, so grouping is purely a visual layer.
   - Also fixes a small bug: photo nav arrow-key handler now ignores
     events fired while focus is in an INPUT or TEXTAREA.

Sticky header overlay (the header that stays at the top while you
scroll past photos in its group) is intentionally deferred — inline
headers already give the visual grouping; the sticky behaviour is
polish for a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:28:16 +02:00
parent ee6b49952e
commit 5f11698907
5 changed files with 288 additions and 100 deletions

View File

@@ -1,10 +1,11 @@
import { Star, X } from 'lucide-react' import { Star, X, ArrowDown, ArrowUp } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
useFilterStore, useFilterStore,
type MediaType, type MediaType,
type ColorLabel, type ColorLabel,
type FlagFilter, type FlagFilter,
type SortField,
} from '../../store/filterStore' } from '../../store/filterStore'
const MEDIA_TYPES: { value: MediaType; label: string }[] = [ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
@@ -28,6 +29,14 @@ const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
{ value: 'discarded', label: 'Discarded' }, { value: 'discarded', label: 'Discarded' },
] ]
const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: 'taken_at', label: 'Date taken' },
{ value: 'added_at', label: 'Date added' },
{ value: 'filename', label: 'Filename' },
{ value: 'file_size', label: 'File size' },
{ value: 'rating', label: 'Rating' },
]
export function FilterBar() { export function FilterBar() {
const filterBarOpen = useFilterStore((s) => s.filterBarOpen) const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
const dateFrom = useFilterStore((s) => s.dateFrom) const dateFrom = useFilterStore((s) => s.dateFrom)
@@ -36,6 +45,8 @@ export function FilterBar() {
const ratingMin = useFilterStore((s) => s.ratingMin) const ratingMin = useFilterStore((s) => s.ratingMin)
const colorLabel = useFilterStore((s) => s.colorLabel) const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag) const flag = useFilterStore((s) => s.flag)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const setDateFrom = useFilterStore((s) => s.setDateFrom) const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo) const setDateTo = useFilterStore((s) => s.setDateTo)
@@ -43,6 +54,8 @@ export function FilterBar() {
const setRatingMin = useFilterStore((s) => s.setRatingMin) const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setColorLabel = useFilterStore((s) => s.setColorLabel) const setColorLabel = useFilterStore((s) => s.setColorLabel)
const setFlag = useFilterStore((s) => s.setFlag) const setFlag = useFilterStore((s) => s.setFlag)
const setSortBy = useFilterStore((s) => s.setSortBy)
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
const clearAll = useFilterStore((s) => s.clearAll) const clearAll = useFilterStore((s) => s.clearAll)
if (!filterBarOpen) return null if (!filterBarOpen) return null
@@ -157,6 +170,32 @@ export function FilterBar() {
})} })}
</Group> </Group>
{/* Sort */}
<Group label="Sort">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="rounded bg-surface-2 p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title={sortOrder === 'desc' ? 'Descending (click for ascending)' : 'Ascending (click for descending)'}
>
{sortOrder === 'desc' ? (
<ArrowDown className="h-3.5 w-3.5" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</button>
</Group>
<button <button
onClick={clearAll} onClick={clearAll}
className="ml-auto rounded border border-border px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text" className="ml-auto rounded border border-border px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text"

View File

@@ -1,11 +1,109 @@
import { useRef, useEffect, useMemo, useState } from 'react' import { useRef, useEffect, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers.
const THUMBNAIL_SIZE = 200
const GAP = 4
const PADDING = 16
const HEADER_HEIGHT = 36
interface PhotoCell {
photo: Photo
globalIndex: number
}
type TimelineItem =
| { type: 'header'; key: string; label: string; height: number }
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
/**
* Build groups by month label when sorted by a date field. For non-temporal
* sorts (filename / file_size / rating) we return a single un-headered group.
*/
function buildItems(
photos: Photo[],
columns: number,
sortBy: string
): TimelineItem[] {
if (photos.length === 0) return []
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
const items: TimelineItem[] = []
// Helper: split a flat array of cells into rows of `columns` cells.
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
for (let i = 0; i < cells.length; i += columns) {
const slice = cells.slice(i, i + columns)
items.push({
type: 'row',
key: `${groupKey}::row::${i}`,
cells: slice,
height: THUMBNAIL_SIZE + GAP,
})
}
}
if (!isDateSort) {
// No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
photo,
globalIndex,
}))
pushRowsForGroup('all', cells)
return items
}
// Walk photos in order, breaking into groups whenever the month label changes.
let currentLabel: string | null = null
let bucket: PhotoCell[] = []
let bucketIndex = 0
const flushBucket = () => {
if (bucket.length === 0 || currentLabel === null) return
items.push({
type: 'header',
key: `header::${bucketIndex}::${currentLabel}`,
label: currentLabel,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
bucketIndex++
bucket = []
}
photos.forEach((photo, globalIndex) => {
const dateStr =
sortBy === 'taken_at'
? photo.taken_at
: (photo as any).added_at ?? photo.taken_at
let label: string
if (dateStr) {
try {
label = format(parseISO(dateStr), 'MMMM yyyy')
} catch {
label = 'Unknown date'
}
} else {
label = 'Unknown date'
}
if (label !== currentLabel) {
flushBucket()
currentLabel = label
}
bucket.push({ photo, globalIndex })
})
flushBucket()
return items
}
export function Timeline() { export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null) const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0) const [containerWidth, setContainerWidth] = useState(0)
@@ -20,30 +118,16 @@ export function Timeline() {
openPreview, openPreview,
} = usePhotoStore() } = usePhotoStore()
// Helper function for range selection const sortBy = useFilterStore((s) => s.sortBy)
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
// Select all photos in the range // Calculate number of columns based on container width.
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Thumbnail size configuration
const thumbnailSize = 200 // Base size for thumbnails
const gap = 4
const padding = 16
// Calculate number of columns based on container width
const columns = useMemo(() => { const columns = useMemo(() => {
if (containerWidth === 0) return 4 if (containerWidth === 0) return 4
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap)) return Math.max(
}, [containerWidth, thumbnailSize, gap, padding]) 1,
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
)
}, [containerWidth])
// Shared photos query — both Timeline and PreviewView use the same hook so // Shared photos query — both Timeline and PreviewView use the same hook so
// they share one cache entry, regardless of filter state. // they share one cache entry, regardless of filter state.
@@ -54,40 +138,60 @@ export function Timeline() {
// subscribing to the same query. // subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers() const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Group photos into rows for grid layout // Build the flat virtualizer items: a mix of date-group headers and rows
const rows = useMemo(() => { // of photos. Headers only appear when sorted by a date field.
const result: Photo[][] = [] const items = useMemo(
for (let i = 0; i < photos.length; i += columns) { () => buildItems(photos, columns, sortBy),
result.push(photos.slice(i, i + columns)) [photos, columns, sortBy]
} )
return result
}, [photos, columns])
// Virtual scrolling setup // Range-selection helper. Operates on the global photos array, not on
// virtualizer items.
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Virtual scrolling setup with per-item heights.
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: items.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => thumbnailSize + gap, estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
overscan: 5, overscan: 5,
}) })
// Measure container width on mount and resize // Re-measure when items change (column count, group structure).
useEffect(() => {
virtualizer.measure()
}, [items, virtualizer])
// Measure container width on mount and resize.
useEffect(() => { useEffect(() => {
const measureWidth = () => { const measureWidth = () => {
if (parentRef.current) { if (parentRef.current) {
setContainerWidth(parentRef.current.clientWidth) setContainerWidth(parentRef.current.clientWidth)
} }
} }
measureWidth() measureWidth()
window.addEventListener('resize', measureWidth) window.addEventListener('resize', measureWidth)
return () => window.removeEventListener('resize', measureWidth) return () => window.removeEventListener('resize', measureWidth)
}, []) }, [])
// Handle keyboard shortcuts for photo navigation // Handle keyboard shortcuts for photo navigation. Operates on the flat
// photos array, so it ignores grouping.
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (photos.length === 0) return if (photos.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
const currentIndex = lastSelectedIndex ?? -1 const currentIndex = lastSelectedIndex ?? -1
@@ -96,54 +200,37 @@ export function Timeline() {
e.preventDefault() e.preventDefault()
if (currentIndex > columns - 1) { if (currentIndex > columns - 1) {
const newIndex = currentIndex - columns const newIndex = currentIndex - columns
if (e.shiftKey) { if (e.shiftKey) selectRange(newIndex)
selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
} }
break break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
if (currentIndex < photos.length - columns) { if (currentIndex < photos.length - columns) {
const newIndex = Math.min(currentIndex + columns, photos.length - 1) const newIndex = Math.min(currentIndex + columns, photos.length - 1)
if (e.shiftKey) { if (e.shiftKey) selectRange(newIndex)
selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
} }
break break
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault() e.preventDefault()
if (currentIndex > 0) { if (currentIndex > 0) {
const newIndex = currentIndex - 1 const newIndex = currentIndex - 1
if (e.shiftKey) { if (e.shiftKey) selectRange(newIndex)
selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
} }
break break
case 'ArrowRight': case 'ArrowRight':
e.preventDefault() e.preventDefault()
if (currentIndex < photos.length - 1) { if (currentIndex < photos.length - 1) {
const newIndex = currentIndex + 1 const newIndex = currentIndex + 1
if (e.shiftKey) { if (e.shiftKey) selectRange(newIndex)
selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
} }
break break
case 'a': case 'a':
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault() e.preventDefault()
// Select all
photos.forEach((photo, index) => { photos.forEach((photo, index) => {
if (!selectedPhotos.includes(photo.id)) { if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index) togglePhotoSelection(photo.id, index)
@@ -151,7 +238,6 @@ export function Timeline() {
}) })
} }
break break
case 'Escape': case 'Escape':
e.preventDefault() e.preventDefault()
clearSelection() clearSelection()
@@ -161,7 +247,8 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [photos, selectedPhotos, lastSelectedIndex, columns])
if (isLoading) { if (isLoading) {
return ( return (
@@ -183,7 +270,7 @@ export function Timeline() {
<div <div
ref={parentRef} ref={parentRef}
className="h-full overflow-auto bg-bg" className="h-full overflow-auto bg-bg"
style={{ padding: `${padding}px` }} style={{ padding: `${PADDING}px` }}
> >
<div <div
style={{ style={{
@@ -192,31 +279,50 @@ export function Timeline() {
position: 'relative', position: 'relative',
}} }}
> >
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualRow.index] const item = items[virtualItem.index]
if (!item) return null
if (item.type === 'header') {
return ( return (
<div <div
key={virtualRow.key} key={virtualItem.key}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: `${virtualRow.size}px`, height: `${virtualItem.size}px`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualItem.start}px)`,
}}
className="flex items-end pb-1"
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
{item.label}
</h3>
</div>
)
}
// row
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}} }}
> >
<div <div className="flex" style={{ gap: `${GAP}px` }}>
className="flex" {item.cells.map(({ photo, globalIndex }) => (
style={{ gap: `${gap}px` }}
>
{row.map((photo, colIndex) => {
const globalIndex = virtualRow.index * columns + colIndex
return (
<PhotoThumbnail <PhotoThumbnail
key={photo.id} key={photo.id}
photo={photo} photo={photo}
size={thumbnailSize} size={THUMBNAIL_SIZE}
isSelected={selectedPhotos.includes(photo.id)} isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)} isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => { onClick={(e) => {
@@ -230,8 +336,7 @@ export function Timeline() {
}} }}
onDoubleClick={() => openPreview(photo.id)} onDoubleClick={() => openPreview(photo.id)}
/> />
) ))}
})}
</div> </div>
</div> </div>
) )

View File

@@ -5,6 +5,8 @@ import {
type MediaType, type MediaType,
type ColorLabel, type ColorLabel,
type FlagFilter, type FlagFilter,
type SortField,
type SortOrder,
} from '../store/filterStore' } from '../store/filterStore'
const ALLOWED_MEDIA: MediaType[] = ['photo', 'video', 'raw', 'heic'] const ALLOWED_MEDIA: MediaType[] = ['photo', 'video', 'raw', 'heic']
@@ -17,6 +19,14 @@ const ALLOWED_COLORS: ColorLabel[] = [
'purple', 'purple',
] ]
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded'] const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
const ALLOWED_SORT_FIELDS: SortField[] = [
'taken_at',
'added_at',
'filename',
'file_size',
'rating',
]
const ALLOWED_SORT_ORDERS: SortOrder[] = ['asc', 'desc']
function parseUrl(): Partial<FilterState> { function parseUrl(): Partial<FilterState> {
const sp = new URLSearchParams(window.location.search) const sp = new URLSearchParams(window.location.search)
@@ -58,6 +68,16 @@ function parseUrl(): Partial<FilterState> {
const heapId = sp.get('heap_id') const heapId = sp.get('heap_id')
if (heapId) out.heapId = heapId if (heapId) out.heapId = heapId
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
out.sortBy = sortBy as SortField
}
const sortOrder = sp.get('order')
if (sortOrder && ALLOWED_SORT_ORDERS.includes(sortOrder as SortOrder)) {
out.sortOrder = sortOrder as SortOrder
}
return out return out
} }
@@ -71,6 +91,8 @@ function writeUrl(f: FilterState) {
if (f.colorLabel) sp.set('color_label', f.colorLabel) if (f.colorLabel) sp.set('color_label', f.colorLabel)
if (f.flag !== 'any') sp.set('flag', f.flag) if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId) if (f.heapId) sp.set('heap_id', f.heapId)
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
const search = sp.toString() const search = sp.toString()
const next = search ? `?${search}` : window.location.pathname const next = search ? `?${search}` : window.location.pathname

View File

@@ -19,6 +19,8 @@ export function usePhotosQuery() {
const colorLabel = useFilterStore((s) => s.colorLabel) const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag) const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId) const heapId = useFilterStore((s) => s.heapId)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const filterParams = useMemo( const filterParams = useMemo(
() => () =>
@@ -31,8 +33,10 @@ export function usePhotosQuery() {
colorLabel, colorLabel,
flag, flag,
heapId, heapId,
sortBy,
sortOrder,
}), }),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId] [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, sortBy, sortOrder]
) )
return useQuery({ return useQuery({
@@ -44,8 +48,6 @@ export function usePhotosQuery() {
params: { params: {
page: 1, page: 1,
per_page: 500, per_page: 500,
sort: 'taken_at',
order: 'desc',
...filterParams, ...filterParams,
}, },
} }

View File

@@ -3,6 +3,13 @@ import { create } from 'zustand'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic' export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
export type FlagFilter = 'any' | 'discarded' export type FlagFilter = 'any' | 'discarded'
export type SortField =
| 'taken_at'
| 'added_at'
| 'filename'
| 'file_size'
| 'rating'
export type SortOrder = 'asc' | 'desc'
export interface FilterState { export interface FilterState {
q: string q: string
@@ -15,6 +22,8 @@ export interface FilterState {
/** When set, restrict to photos in this heap. Independent of `activeHeapId` /** When set, restrict to photos in this heap. Independent of `activeHeapId`
* on the heap store — that's the target for the T shortcut. */ * on the heap store — that's the target for the T shortcut. */
heapId: string | null heapId: string | null
sortBy: SortField
sortOrder: SortOrder
} }
interface FilterStore extends FilterState { interface FilterStore extends FilterState {
@@ -28,6 +37,9 @@ interface FilterStore extends FilterState {
setColorLabel: (label: ColorLabel | null) => void setColorLabel: (label: ColorLabel | null) => void
setFlag: (flag: FlagFilter) => void setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void setHeapId: (id: string | null) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
setFilterBarOpen: (open: boolean) => void setFilterBarOpen: (open: boolean) => void
toggleFilterBar: () => void toggleFilterBar: () => void
@@ -45,6 +57,8 @@ export const INITIAL_FILTERS: FilterState = {
colorLabel: null, colorLabel: null,
flag: 'any', flag: 'any',
heapId: null, heapId: null,
sortBy: 'taken_at',
sortOrder: 'desc',
} }
export const useFilterStore = create<FilterStore>((set) => ({ export const useFilterStore = create<FilterStore>((set) => ({
@@ -64,6 +78,10 @@ export const useFilterStore = create<FilterStore>((set) => ({
setColorLabel: (colorLabel) => set({ colorLabel }), setColorLabel: (colorLabel) => set({ colorLabel }),
setFlag: (flag) => set({ flag }), setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }), setHeapId: (heapId) => set({ heapId }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })),
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }), setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })), toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
@@ -84,6 +102,8 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.colorLabel) params.color_label = f.colorLabel if (f.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'discarded') params.is_discarded = 'true' if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId if (f.heapId) params.heap_id = f.heapId
params.sort = f.sortBy
params.order = f.sortOrder
return params return params
} }