- {virtualizer.getVirtualItems().map((virtualRow) => {
- const row = rows[virtualRow.index]
+ {virtualizer.getVirtualItems().map((virtualItem) => {
+ const item = items[virtualItem.index]
+ if (!item) return null
+
+ if (item.type === 'header') {
+ return (
+
+
+ {item.label}
+
+
+ )
+ }
+
+ // row
return (
-
- {row.map((photo, colIndex) => {
- const globalIndex = virtualRow.index * columns + colIndex
- return (
-
{
- if (e.shiftKey && lastSelectedIndex !== null) {
- selectRange(globalIndex)
- } else if (e.ctrlKey || e.metaKey) {
- togglePhotoSelection(photo.id, globalIndex)
- } else {
- selectPhoto(photo.id, globalIndex)
- }
- }}
- onDoubleClick={() => openPreview(photo.id)}
- />
- )
- })}
+
+ {item.cells.map(({ photo, globalIndex }) => (
+
{
+ if (e.shiftKey && lastSelectedIndex !== null) {
+ selectRange(globalIndex)
+ } else if (e.ctrlKey || e.metaKey) {
+ togglePhotoSelection(photo.id, globalIndex)
+ } else {
+ selectPhoto(photo.id, globalIndex)
+ }
+ }}
+ onDoubleClick={() => openPreview(photo.id)}
+ />
+ ))}
)
@@ -239,4 +344,4 @@ export function Timeline() {
)
-}
\ No newline at end of file
+}
diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts
index 8f92976..8a9f6fb 100644
--- a/frontend/src/hooks/useFilterUrlSync.ts
+++ b/frontend/src/hooks/useFilterUrlSync.ts
@@ -5,6 +5,8 @@ import {
type MediaType,
type ColorLabel,
type FlagFilter,
+ type SortField,
+ type SortOrder,
} from '../store/filterStore'
const ALLOWED_MEDIA: MediaType[] = ['photo', 'video', 'raw', 'heic']
@@ -17,6 +19,14 @@ const ALLOWED_COLORS: ColorLabel[] = [
'purple',
]
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
{
const sp = new URLSearchParams(window.location.search)
@@ -58,6 +68,16 @@ function parseUrl(): Partial {
const heapId = sp.get('heap_id')
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
}
@@ -71,6 +91,8 @@ function writeUrl(f: FilterState) {
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)
+ if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
+ if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
const search = sp.toString()
const next = search ? `?${search}` : window.location.pathname
diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts
index 580d3cf..c8f310d 100644
--- a/frontend/src/hooks/usePhotosQuery.ts
+++ b/frontend/src/hooks/usePhotosQuery.ts
@@ -19,6 +19,8 @@ export function usePhotosQuery() {
const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
+ const sortBy = useFilterStore((s) => s.sortBy)
+ const sortOrder = useFilterStore((s) => s.sortOrder)
const filterParams = useMemo(
() =>
@@ -31,8 +33,10 @@ export function usePhotosQuery() {
colorLabel,
flag,
heapId,
+ sortBy,
+ sortOrder,
}),
- [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId]
+ [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, sortBy, sortOrder]
)
return useQuery({
@@ -44,8 +48,6 @@ export function usePhotosQuery() {
params: {
page: 1,
per_page: 500,
- sort: 'taken_at',
- order: 'desc',
...filterParams,
},
}
diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts
index a70e105..c27d8a7 100644
--- a/frontend/src/store/filterStore.ts
+++ b/frontend/src/store/filterStore.ts
@@ -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((set) => ({
@@ -64,6 +78,10 @@ export const useFilterStore = create((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
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
}