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:
@@ -1,15 +1,113 @@
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
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() {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
|
||||
const {
|
||||
selectedPhotos,
|
||||
lastSelectedIndex,
|
||||
@@ -19,31 +117,17 @@ export function Timeline() {
|
||||
clearSelection,
|
||||
openPreview,
|
||||
} = usePhotoStore()
|
||||
|
||||
// Helper function for range selection
|
||||
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
|
||||
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
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
|
||||
// Calculate number of columns based on container width
|
||||
// Calculate number of columns based on container width.
|
||||
const columns = useMemo(() => {
|
||||
if (containerWidth === 0) return 4
|
||||
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
||||
}, [containerWidth, thumbnailSize, gap, padding])
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
|
||||
)
|
||||
}, [containerWidth])
|
||||
|
||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||
// they share one cache entry, regardless of filter state.
|
||||
@@ -54,40 +138,60 @@ export function Timeline() {
|
||||
// subscribing to the same query.
|
||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
// Group photos into rows for grid layout
|
||||
const rows = useMemo(() => {
|
||||
const result: Photo[][] = []
|
||||
for (let i = 0; i < photos.length; i += columns) {
|
||||
result.push(photos.slice(i, i + columns))
|
||||
}
|
||||
return result
|
||||
}, [photos, columns])
|
||||
// Build the flat virtualizer items: a mix of date-group headers and rows
|
||||
// of photos. Headers only appear when sorted by a date field.
|
||||
const items = useMemo(
|
||||
() => buildItems(photos, columns, sortBy),
|
||||
[photos, columns, sortBy]
|
||||
)
|
||||
|
||||
// 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({
|
||||
count: rows.length,
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => thumbnailSize + gap,
|
||||
estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
|
||||
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(() => {
|
||||
const measureWidth = () => {
|
||||
if (parentRef.current) {
|
||||
setContainerWidth(parentRef.current.clientWidth)
|
||||
}
|
||||
}
|
||||
|
||||
measureWidth()
|
||||
window.addEventListener('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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
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
|
||||
|
||||
@@ -96,54 +200,37 @@ export function Timeline() {
|
||||
e.preventDefault()
|
||||
if (currentIndex > columns - 1) {
|
||||
const newIndex = currentIndex - columns
|
||||
if (e.shiftKey) {
|
||||
selectRange(newIndex)
|
||||
} else {
|
||||
selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
if (e.shiftKey) selectRange(newIndex)
|
||||
else selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (currentIndex < photos.length - columns) {
|
||||
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
||||
if (e.shiftKey) {
|
||||
selectRange(newIndex)
|
||||
} else {
|
||||
selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
if (e.shiftKey) selectRange(newIndex)
|
||||
else selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
const newIndex = currentIndex - 1
|
||||
if (e.shiftKey) {
|
||||
selectRange(newIndex)
|
||||
} else {
|
||||
selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
if (e.shiftKey) selectRange(newIndex)
|
||||
else selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
if (currentIndex < photos.length - 1) {
|
||||
const newIndex = currentIndex + 1
|
||||
if (e.shiftKey) {
|
||||
selectRange(newIndex)
|
||||
} else {
|
||||
selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
if (e.shiftKey) selectRange(newIndex)
|
||||
else selectPhoto(photos[newIndex].id, newIndex)
|
||||
}
|
||||
break
|
||||
|
||||
case 'a':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
// Select all
|
||||
photos.forEach((photo, index) => {
|
||||
if (!selectedPhotos.includes(photo.id)) {
|
||||
togglePhotoSelection(photo.id, index)
|
||||
@@ -151,7 +238,6 @@ export function Timeline() {
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
clearSelection()
|
||||
@@ -161,7 +247,8 @@ export function Timeline() {
|
||||
|
||||
window.addEventListener('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) {
|
||||
return (
|
||||
@@ -183,7 +270,7 @@ export function Timeline() {
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto bg-bg"
|
||||
style={{ padding: `${padding}px` }}
|
||||
style={{ padding: `${PADDING}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -192,46 +279,64 @@ export function Timeline() {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}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={virtualRow.key}
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
style={{ gap: `${gap}px` }}
|
||||
>
|
||||
{row.map((photo, colIndex) => {
|
||||
const globalIndex = virtualRow.index * columns + colIndex
|
||||
return (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
size={thumbnailSize}
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
onClick={(e) => {
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="flex" style={{ gap: `${GAP}px` }}>
|
||||
{item.cells.map(({ photo, globalIndex }) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
size={THUMBNAIL_SIZE}
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
onClick={(e) => {
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -239,4 +344,4 @@ export function Timeline() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user