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>
885 lines
33 KiB
TypeScript
885 lines
33 KiB
TypeScript
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 1–5 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.',
|
||
}
|
||
}
|
||
}
|