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:
@@ -3,6 +3,13 @@ import { create } from 'zustand'
|
||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
export type FlagFilter = 'any' | 'discarded'
|
||||
export type SortField =
|
||||
| 'taken_at'
|
||||
| 'added_at'
|
||||
| 'filename'
|
||||
| 'file_size'
|
||||
| 'rating'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
@@ -15,6 +22,8 @@ export interface FilterState {
|
||||
/** 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
|
||||
sortBy: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
|
||||
interface FilterStore extends FilterState {
|
||||
@@ -28,6 +37,9 @@ interface FilterStore extends FilterState {
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
setSortBy: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
toggleSortOrder: () => void
|
||||
|
||||
setFilterBarOpen: (open: boolean) => void
|
||||
toggleFilterBar: () => void
|
||||
@@ -45,6 +57,8 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
sortBy: 'taken_at',
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
|
||||
export const useFilterStore = create<FilterStore>((set) => ({
|
||||
@@ -64,6 +78,10 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
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 }),
|
||||
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.flag === 'discarded') params.is_discarded = 'true'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
params.sort = f.sortBy
|
||||
params.order = f.sortOrder
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user