Files
mule-image/frontend/src/components/timeline/Timeline.tsx
dtoro e65e798021 perf+ux: cut grid re-renders, coalesce discard, dedup bulk mutations
Frontend cleanup pass driven by the post-shadcn review.

Performance
- Memoize PhotoThumbnail and route cell click/double-click through
  stable handlers so heap-membership invalidation no longer re-renders
  every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
  50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
  pulse skeleton; only retry state still surfaces a spinner.

UX
- Coalesce rapid X/U presses into a single undo entry + one toast
  (1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
  error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
  tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
  validation arrays, axios message, with a 'Network Error' filter.

Architecture
- Extract useBulkPhotoMutations and stop duplicating
  bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
  into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
  TagsEditor, TakenAtEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:13:39 +02:00

885 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { Button } from '@/components/ui/button'
import { ImageOff } from 'lucide-react'
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 the flat header|row item array the virtualizer renders.
*
* Two modes:
* - sortBy is a date field: month buckets.
* - otherwise: one un-headered stream.
*
* Tag, rating, and color grouping now live in their own dedicated views
* (TagsView, RatedView, ColorsView) instead of being handled here.
*/
function buildItems(
photos: Photo[],
columns: number,
rowHeight: number,
sortBy: string,
groupBy: string,
): TimelineItem[] {
if (photos.length === 0) return []
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: rowHeight + GAP,
})
}
}
// Date grouping only applies when groupBy is explicitly 'date' and
// the sort field is a date column. Other sections (tags, colors,
// rated, people) reuse Timeline for their detail views and should
// render a flat grid without month headers.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort || groupBy !== 'date') {
// 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.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)
// Sentinel placed inside the inner virtualizer wrapper at the exact
// position rows will render. We measure THIS instead of parentRef,
// because parentRef has padding and we'd otherwise have to subtract
// it (and account for any scrollbar) — easy to get wrong by a pixel
// and end up with a column count off by one.
const [containerWidth, setContainerWidth] = useState(0)
const {
selectedPhotos,
activePhotoId,
selectPhoto,
togglePhotoSelection,
selectRange,
clearSelection,
openPreview,
} = usePhotoStore()
// Pulled via a focused selector so the publisher subscription doesn't
// re-render Timeline on every unrelated photo store change.
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
const currentSection = useFilterStore((s) => s.currentSection)
const viewMode = usePhotoStore((s) => s.viewMode)
// Calculate number of columns + actual cell size based on container
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
// fill the remaining space, so we never leave a horizontal gap on
// the right side of the grid.
//
// Column math: with N columns there are N-1 inter-cell gaps, so the
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
// in the available width gives N = floor((available + G) / (T + G)).
// The previous formula floor((available) / (T + G)) was off-by-one
// and lost a whole column whenever the remainder almost fit.
const { columns, cellSize } = useMemo(() => {
if (containerWidth === 0) {
return { columns: 4, cellSize: THUMBNAIL_SIZE }
}
// containerWidth here is the sentinel's actual rendered width — no
// padding subtraction needed, the sentinel already lives inside the
// padded scroll container.
const available = containerWidth
const cols = Math.max(
1,
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
)
// Exact float — no floor. cellSize × cols + (cols-1) × gap == available
// by construction, so the row fills edge-to-edge without any
// sub-pixel rounding gap.
const cell = (available - (cols - 1) * GAP) / cols
return { columns: cols, cellSize: cell }
}, [containerWidth])
// Shared photos query — both Timeline and PreviewView use the same hook so
// they share one cache entry, regardless of filter state.
const { data: photos = [], isLoading } = usePhotosQuery()
// Tracks the previous viewMode so the "preview just closed" scroll
// effect (defined further down, after photoRows) only fires on the
// actual transition rather than every items[] recomputation.
const prevViewModeRef = useRef(viewMode)
// Auto-focus the first photo on initial grid load so arrow-key nav
// works immediately without a pre-click. One-shot — after the user
// explicitly clears the selection (Escape), we don't re-focus, so
// the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
useEffect(() => {
if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return
if (activePhotoId) {
didAutoFocusRef.current = true
return
}
if (photos.length === 0) return
didAutoFocusRef.current = true
selectPhoto(photos[0].id)
}, [viewMode, activePhotoId, photos, selectPhoto])
// Membership in the active heap (drives the green tint on each
// thumbnail). Subscribed once at this level so we don't have hundreds
// of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
// identity-stable callbacks let it skip re-render on unrelated store
// churn (e.g. heap membership invalidation). visibleSequence is read
// through a ref at click time so scrolling doesn't rebind the
// double-click handler.
const visibleSequenceRef = useRef<string[]>([])
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
else selectPhoto(photo.id)
},
[selectRange, togglePhotoSelection, selectPhoto],
)
const handleCellDoubleClick = useCallback(
(photo: Photo) => {
openPreview(photo.id, visibleSequenceRef.current)
},
[openPreview],
)
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo(
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
[photos, columns, cellSize, sortBy, groupBy]
)
// Used by the sticky header, the row-date index, and the floating
// scrollbar chip — all three only make sense in date-sorted views.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
// Pre-computed offset of every header in the virtualizer's coordinate
// space, used to drive the sticky-header overlay below.
const headerOffsets = useMemo(() => {
const result: { offset: number; label: string }[] = []
let cumulative = 0
for (const item of items) {
if (item.type === 'header') {
result.push({ offset: cumulative, label: item.label })
}
cumulative += item.height
}
return result
}, [items])
// Virtual scrolling setup with per-item heights.
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => items[index]?.height ?? cellSize,
overscan: 5,
})
// Re-measure when items change (column count, group structure).
useEffect(() => {
virtualizer.measure()
}, [items, virtualizer])
// Track scroll position so we can (a) show the current group label as
// a pinned overlay at the top of the scroll container and (b) drive
// the floating "date next to the scrollbar" indicator. The virtualizer's
// items use transform translateY (so CSS position: sticky doesn't work
// on the inline headers); the overlays sidestep that by living outside
// the virtualizer's positioned children.
//
// We capture clientHeight + scrollHeight alongside top so the floating
// indicator can position itself proportionally to scroll progress
// without needing a second observer to react to viewport resizes.
// `isScrolling` is a short-lived flag reset by a debounced timer —
// drives the fade in/out of the date chip so it only appears while
// the user is actively dragging the scrollbar / wheel-scrolling.
const [scrollMetrics, setScrollMetrics] = useState({
top: 0,
clientHeight: 0,
scrollHeight: 0,
})
const [isScrolling, setIsScrolling] = useState(false)
const scrollIdleTimerRef = useRef<number | null>(null)
useEffect(() => {
const el = parentRef.current
if (!el) return
const capture = () => {
setScrollMetrics({
top: el.scrollTop,
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
})
}
capture()
const onScroll = () => {
capture()
setIsScrolling(true)
if (scrollIdleTimerRef.current !== null) {
window.clearTimeout(scrollIdleTimerRef.current)
}
scrollIdleTimerRef.current = window.setTimeout(() => {
setIsScrolling(false)
scrollIdleTimerRef.current = null
}, 700)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (scrollIdleTimerRef.current !== null) {
window.clearTimeout(scrollIdleTimerRef.current)
scrollIdleTimerRef.current = null
}
}
// Depend on isLoading + photo presence so the effect re-attaches
// after the early-return loading/empty JSX gives way to the real
// scroll container — on first mount parentRef.current is null and
// an empty-deps effect would never bind the listener.
}, [isLoading, photos.length])
// Alias for the sticky-header math below, which only needs the scroll
// offset. Kept as a local so the existing logic reads the same as
// before the metrics refactor.
const scrollTop = scrollMetrics.top
// Parallel index of every row's (cumulative offset, raw date string),
// built from the same flat items array the virtualizer reads. Used by
// the floating scrollbar-date chip to answer "what date is the user
// currently looking at" in O(log n) without reaching into the
// virtualizer's internals. Rebuilt whenever items or sort field change.
const rowDateIndex = useMemo(() => {
if (!isDateSort) return [] as { offset: number; raw: string | null }[]
const out: { offset: number; raw: string | null }[] = []
let cum = 0
for (const item of items) {
if (item.type === 'row') {
const first = item.cells[0]?.photo
const raw =
sortBy === 'added_at'
? first?.added_at ?? first?.taken_at ?? null
: first?.taken_at ?? null
out.push({ offset: cum, raw })
}
cum += item.height
}
return out
}, [items, sortBy, isDateSort])
// Date shown in the floating chip next to the scrollbar. Finds the
// deepest row whose offset is at/above the viewport top (plus a
// small lookahead so fast flicks feel responsive) and formats its
// photo's capture date. Null when not date-sorted, not scrolling, or
// when the topmost row has no date — we'd rather hide the chip than
// show an unhelpful "Unknown".
const scrollDateLabel = useMemo(() => {
if (!isDateSort || rowDateIndex.length === 0) return null
const target = scrollMetrics.top + 20
let raw: string | null = null
// Binary search: largest offset ≤ target.
let lo = 0
let hi = rowDateIndex.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (rowDateIndex[mid].offset <= target) {
raw = rowDateIndex[mid].raw
lo = mid + 1
} else {
hi = mid - 1
}
}
if (!raw) return null
try {
return format(parseISO(raw), 'MMM d, yyyy')
} catch {
return null
}
}, [rowDateIndex, scrollMetrics.top, isDateSort])
// Vertical position of the floating chip in scroll-viewport pixels.
// Tracks the scrollbar thumb's position by mapping scroll progress
// linearly onto the viewport height. Clamped so the chip never
// overflows the top/bottom padding even at scroll extremes.
const scrollIndicatorTop = useMemo(() => {
const { top, clientHeight, scrollHeight } = scrollMetrics
if (scrollHeight <= clientHeight) return 8
const ratio = top / (scrollHeight - clientHeight)
const chipHeight = 28
const usable = clientHeight - chipHeight - 16
return 8 + Math.max(0, Math.min(usable, ratio * usable))
}, [scrollMetrics])
// Find the latest header whose BOTTOM is above the viewport top. That's
// the group whose natural in-grid header has scrolled out of view —
// exactly the case where we want to pin the label as a sticky overlay.
// If the natural header is still visible (scrolled but not yet past),
// we return null and let the in-grid label do the work, avoiding the
// duplicate-label flash.
const stickyLabel = useMemo(() => {
if (headerOffsets.length === 0) return null
let current: string | null = null
for (const h of headerOffsets) {
if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
else break
}
return current
}, [headerOffsets, scrollTop])
// Measure the sentinel's actual rendered width on mount, window
// resize, and any layout change driven by the sidebar collapse /
// right panel toggle. ResizeObserver picks up everything window
// resize misses (sidebar collapse doesn't fire window resize).
//
// Uses a callback ref (not useRef + useEffect) because Timeline
// early-returns a loading/empty state before the sentinel exists,
// so a mount-only effect would see a null ref and never install
// the observer. The callback ref fires whenever the sentinel
// actually attaches, which is the moment we can measure it.
const roRef = useRef<ResizeObserver | null>(null)
const measureElRef = useRef<HTMLDivElement | null>(null)
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect()
roRef.current = null
measureElRef.current = el
if (!el) return
const measure = () => setContainerWidth(el.clientWidth)
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
roRef.current = ro
}, [])
useEffect(() => {
const onResize = () => {
const el = measureElRef.current
if (el) setContainerWidth(el.clientWidth)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
roRef.current?.disconnect()
roRef.current = null
}
}, [])
// Photo rows in visual order — drops the header items so navigation
// walks the grid as the user sees it. Each row has cells of length
// [1..columns], the last row of a group can be short, and a single
// photo with multiple tags will appear in multiple rows.
const photoRows = useMemo(
() => items.filter((it): it is Extract<TimelineItem, { type: 'row' }> => it.type === 'row'),
[items]
)
// Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]].
// Lets keyboard nav jump the virtualizer to the destination row even when
// it hasn't been rendered yet (beyond the overscan window).
const photoRowItemIndex = useMemo(() => {
const map: number[] = []
items.forEach((it, idx) => {
if (it.type === 'row') map.push(idx)
})
return map
}, [items])
// After the preview closes, scroll the photo it was originally
// opened on back into view. The store's closePreview already
// restored activePhotoId to that origin id; we just need to make
// sure it's actually visible in the scroll viewport. Guarded by
// prevViewModeRef so this only fires on the actual preview→grid
// transition, not every time photoRows recomputes.
useEffect(() => {
const prev = prevViewModeRef.current
prevViewModeRef.current = viewMode
if (prev !== 'preview' || viewMode !== 'grid') return
if (!activePhotoId) return
let rowIdx = -1
for (let r = 0; r < photoRows.length; r++) {
if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) {
rowIdx = r
break
}
}
if (rowIdx < 0) return
const itemIdx = photoRowItemIndex[rowIdx]
const scrollEl = parentRef.current
if (itemIdx === undefined || !scrollEl) return
let rowTop = 0
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
const rowHeight = items[itemIdx].height
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
// Center the row in the viewport — the user is returning to a
// specific photo, not resuming a scroll, so context above and
// below is what they want.
const target = Math.max(
0,
rowTop - scrollEl.clientHeight / 2 + rowHeight / 2
)
scrollEl.scrollTo({ top: target })
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
// Flat visible-order id sequence — exactly the order the user reads
// off the grid (top-to-bottom, left-to-right within each row).
// Includes duplicates from tag-grouping; landing on the same photo's
// "second" appearance in the next tag bucket is the right behavior
// in tag mode.
const visibleSequence = useMemo(() => {
const ids: string[] = []
for (const row of photoRows) {
for (const cell of row.cells) {
ids.push(cell.photo.id)
}
}
return ids
}, [photoRows])
// Publish to the photo store so PreviewView's arrow nav and filmstrip
// can walk the same order even when opened from a non-click path
// (e.g. the global Space hotkey).
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
visibleSequenceRef.current = visibleSequence
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
// can repeat a photo across groups. Returns null when there's no
// active photo or it isn't currently rendered.
const findActiveCell = (): { row: number; col: number } | null => {
if (!activePhotoId) return null
for (let r = 0; r < photoRows.length; r++) {
const row = photoRows[r]
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
if (c >= 0) return { row: r, col: c }
}
return null
}
// Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place.
//
// Inert in preview mode — PreviewView mounts its own arrow handlers,
// and a window-level grid handler firing alongside them used to race
// against PreviewView's setActivePhoto, landing the user on the wrong
// photo. The grid handler stays attached so it can re-engage the
// moment the user closes preview.
useEffect(() => {
if (viewMode !== 'grid') return
// The duplicates section mounts its own grouped view (DuplicatesView)
// with its own keyboard nav — bail out so we don't double-handle
// arrow keys and try to navigate against a photoRows snapshot that
// doesn't match what the user actually sees on screen.
if (currentSection === 'duplicates') return
const handleKeyDown = (e: KeyboardEvent) => {
if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
const move = (dr: number, dc: number) => {
const current = findActiveCell() ?? { row: 0, col: -1 }
let nextRow = current.row
let nextCol = current.col + dc
if (dc !== 0) {
// Wrap left/right across row boundaries.
while (nextCol < 0 && nextRow > 0) {
nextRow -= 1
nextCol = photoRows[nextRow].cells.length - 1
}
while (
nextRow < photoRows.length &&
nextCol >= photoRows[nextRow].cells.length
) {
if (nextRow === photoRows.length - 1) {
nextCol = photoRows[nextRow].cells.length - 1
break
}
nextRow += 1
nextCol = 0
}
if (nextCol < 0) nextCol = 0
}
if (dr !== 0) {
nextRow += dr
if (nextRow < 0) nextRow = 0
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
// Clamp the column to the destination row's actual width so
// moving down into a half-full row lands on its last cell
// instead of nothing.
const rowLen = photoRows[nextRow].cells.length
if (nextCol >= rowLen) nextCol = rowLen - 1
if (nextCol < 0) nextCol = 0
}
const dest = photoRows[nextRow]?.cells[nextCol]
if (!dest) return
if (e.shiftKey) {
selectRange(dest.photo.id)
} else {
selectPhoto(dest.photo.id)
}
// Bring the destination row into view if it's off-screen, leaving
// a "peek" margin so the next row above/below stays partly visible
// — cues the user that there's more content in the scroll direction.
// In-viewport moves are a no-op, so same-row arrow presses don't
// jitter the scroll position.
const itemIdx = photoRowItemIndex[nextRow]
const scrollEl = parentRef.current
if (itemIdx !== undefined && scrollEl) {
// Sum item heights up to itemIdx to get this row's offset in the
// virtualizer's coordinate space. Cheap enough at O(items) and
// avoids reaching into virtualizer.measurementsCache internals.
let rowTop = 0
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
const rowHeight = items[itemIdx].height
const peek = Math.round(cellSize * 0.35)
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (rowTop - peek < viewTop) {
// Destination is above (or flush with) the viewport top. Leave
// `peek` pixels of the previous row visible above it.
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
} else if (rowTop + rowHeight + peek > viewBottom) {
// Destination is below the viewport bottom. Leave `peek` pixels
// of the next row visible below it.
scrollEl.scrollTo({
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
})
}
}
}
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
move(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
move(1, 0)
break
case 'ArrowLeft':
e.preventDefault()
move(0, -1)
break
case 'ArrowRight':
e.preventDefault()
move(0, 1)
break
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
photos.forEach((photo) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id)
}
})
}
break
case 'Escape':
e.preventDefault()
clearSelection()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">Loading photos...</div>
</div>
)
}
if (photos.length === 0) {
return <EmptyTimelineState />
}
return (
<div className="relative h-full">
{/* Sticky group-header overlay. Lives outside the virtualizer's
* positioned children so it isn't affected by translateY transforms.
* Updates as the user scrolls past month boundaries. */}
{stickyLabel && (
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b-2 border-border bg-bg/95 px-4 py-1.5 shadow-sm backdrop-blur">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
{stickyLabel}
</h3>
</div>
)}
{/* Floating scrollbar date chip. Sits just inside the right edge,
* vertically tracking the scrollbar thumb's position, and fades
* out 700ms after the user stops scrolling. Read-only/decorative,
* so pointer-events-none — it must never steal scrollbar clicks. */}
{isDateSort && scrollDateLabel && (
<div
className={clsx(
'pointer-events-none absolute right-4 z-30 rounded-md border border-border bg-surface/95 px-2.5 py-1 text-xs font-semibold text-text shadow-lg backdrop-blur transition-opacity duration-200',
isScrolling ? 'opacity-100' : 'opacity-0'
)}
style={{ top: `${scrollIndicatorTop}px` }}
>
{scrollDateLabel}
</div>
)}
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
// Extra bottom padding so the last row clears the floating
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
style={{ padding: `${PADDING}px`, paddingBottom: `${PADDING + 64}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Width sentinel — a 1px-tall normal-flow div that takes the
* full width of the inner virtualizer wrapper, which is the
* exact width rows render at. clientWidth on this is what we
* base the column count on, sidestepping any padding /
* scrollbar mismatch the parentRef-based measurement is
* vulnerable to. ResizeObserver doesn't reliably fire on
* zero-area absolute elements, so 1px tall + relative flow. */}
<div
ref={widthSentinelRef}
aria-hidden="true"
style={{
width: '100%',
height: 1,
marginBottom: -1,
pointerEvents: 'none',
}}
/>
{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">
{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
style={{
// Fixed-size grid: every track is exactly cellSize
// wide and the row is exactly cellSize tall, so
// cells are guaranteed square no matter what CSS
// the cell contents bring along. cellSize was
// already computed from `available / cols` so the
// sum cols*cellSize + (cols-1)*gap equals the
// container width to within sub-pixel rounding.
display: 'grid',
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
gridAutoRows: `${cellSize}px`,
gap: `${GAP}px`,
}}
>
{item.cells.map(({ photo }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={cellSize}
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
))}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
/**
* Empty-state rendered when the current filter/section returns zero photos.
* Distinguishes "library-empty" from "filters-too-strict": the former hints
* at upload, the latter offers a one-click Clear all.
*/
function EmptyTimelineState() {
const filterState = useFilterStore()
const clearAll = useFilterStore((s) => s.clearAll)
const currentSection = useFilterStore((s) => s.currentSection)
const filtersActive = hasActiveFilters(filterState)
const { title, hint } = sectionEmptyCopy(currentSection, filtersActive)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<ImageOff className="h-10 w-10 text-text-muted/40" />
<div className="text-sm font-medium text-text">{title}</div>
<p className="max-w-sm text-xs text-text-muted">{hint}</p>
{filtersActive && (
<Button variant="outline" size="sm" onClick={clearAll} className="mt-1">
Clear all filters
</Button>
)}
</div>
)
}
function sectionEmptyCopy(
section: string,
filtersActive: boolean,
): { title: string; hint: string } {
if (filtersActive) {
return {
title: 'No photos match',
hint: 'Your filters are excluding everything in this section. Clear them to see the full library.',
}
}
switch (section) {
case 'discarded':
return {
title: 'Discard pile is empty',
hint: 'Photos you discard (X) land here until you empty them permanently.',
}
case 'rated':
return {
title: 'No rated photos yet',
hint: 'Rate photos 15 with the number keys and they will appear here.',
}
case 'tags':
return {
title: 'No tagged photos',
hint: 'Add tags from a photo\u2019s metadata panel or via the bulk tag editor.',
}
default:
return {
title: 'Library is empty',
hint: 'Add photos via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.',
}
}
}