feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll

- Right sidebar stays open by default and shows an empty state when
  nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
  expand button in the TopBar that only appears when collapsed, so
  each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
  with a ~35% peek margin, cueing the user that there's more content
  in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
  ResizeObserver when Timeline first rendered the loading state (ref
  was null, empty-dep effect didn't re-run), so containerWidth stuck
  at 0 and the grid fell back to 4 columns × 200px forever. Switched
  to a callback ref that attaches the observer the moment the
  sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:20:22 +02:00
parent a4b1802657
commit d6c667ae78
6 changed files with 189 additions and 61 deletions

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useMemo, useState } from 'react'
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
@@ -167,7 +167,6 @@ export function Timeline() {
// 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 widthSentinelRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const {
@@ -295,17 +294,35 @@ export function Timeline() {
// 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).
useEffect(() => {
const el = widthSentinelRef.current
//
// 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)
window.addEventListener('resize', measure)
roRef.current = ro
}, [])
useEffect(() => {
const onResize = () => {
const el = measureElRef.current
if (el) setContainerWidth(el.clientWidth)
}
window.addEventListener('resize', onResize)
return () => {
ro.disconnect()
window.removeEventListener('resize', measure)
window.removeEventListener('resize', onResize)
roRef.current?.disconnect()
roRef.current = null
}
}, [])
@@ -318,6 +335,17 @@ export function Timeline() {
[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])
// 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
@@ -416,6 +444,35 @@ export function Timeline() {
} 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) {
@@ -455,7 +512,7 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId])
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize])
if (isLoading) {
return (