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,5 +1,5 @@
import { useState } from 'react'
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
import { X, Star, Info, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore'
@@ -21,7 +21,11 @@ import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
* - 2+ photos selected → renders a slim bulk-action panel that fans out
* rating / color / discard / pick across the entire selection.
*/
export function RightSidebar() {
interface RightSidebarProps {
onCollapse: () => void
}
export function RightSidebar({ onCollapse }: RightSidebarProps) {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient()
@@ -134,12 +138,51 @@ export function RightSidebar() {
},
})
// Unified header rendered in every branch so the collapse button is
// always reachable regardless of selection state. Title and the
// clear-selection X adapt to what's selected.
const headerTitle =
selectedPhotos.length === 0
? 'Metadata'
: selectedPhotos.length === 1
? 'Metadata'
: `${selectedPhotos.length} Photos Selected`
const Header = () => (
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">{headerTitle}</h2>
<div className="flex items-center gap-1">
{selectedPhotos.length > 0 && (
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection (Esc)"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
)}
<button
onClick={onCollapse}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (I)"
aria-label="Collapse panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
</div>
)
if (selectedPhotos.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center">
<div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p>
<div className="flex h-full flex-col bg-surface">
<Header />
<div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p>
</div>
</div>
</div>
)
@@ -150,17 +193,7 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Metadata</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<Header />
<PhotoInfoPanel photoId={id} />
</div>
)
@@ -171,18 +204,7 @@ export function RightSidebar() {
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text">
{selectedPhotos.length} Photos Selected
</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<Header />
<div className="space-y-3 border-b border-border p-4">
<p className="text-xs text-text-muted">