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 { useEffect, useState } from 'react'
import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
@@ -18,9 +18,8 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const viewMode = usePhotoStore((state) => state.viewMode)
// Bidirectional sync of filter store with URL query params.
@@ -38,24 +37,19 @@ function App() {
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
})
// Auto-show right sidebar when photos are selected — but only in grid mode,
// so leaving the preview doesn't fight the user's prior sidebar state.
// Lives in an effect (not the render body) to avoid setState-during-render
// and the cascading re-renders the audit caught.
useEffect(() => {
if (viewMode !== 'grid') return
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false)
}
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
// Right sidebar stays open by default and shows whatever's selected
// (or an empty state if nothing is). User can still toggle it manually.
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
return (
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar onOpenSettings={() => setSettingsOpen(true)} />
<TopBar
onOpenSettings={() => setSettingsOpen(true)}
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onExpandLeft={() => setLeftSidebarOpen(true)}
onExpandRight={() => setRightSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
@@ -64,7 +58,7 @@ function App() {
leftSidebarOpen ? 'w-64' : 'w-0'
} overflow-hidden border-r border-border bg-surface`}
>
<LeftSidebar />
<LeftSidebar onCollapse={() => setLeftSidebarOpen(false)} />
</div>
{/* Main column — filter bar, discard bar, timeline. Lives to the
@@ -90,7 +84,7 @@ function App() {
showRightSidebar ? 'w-80' : 'w-0'
} overflow-hidden border-l border-border bg-surface`}
>
<RightSidebar />
<RightSidebar onCollapse={() => setRightSidebarOpen(false)} />
</div>
</div>

View File

@@ -14,13 +14,14 @@ export function KeyboardHints() {
{ key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' },
{ key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' },
{ key: 'Esc', action: 'Deselect' },
]
: [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' },
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]

View File

@@ -14,6 +14,7 @@ import {
Layers2,
MoreHorizontal,
Pencil,
PanelLeftClose,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -41,7 +42,11 @@ interface TreeItem {
type?: 'folder' | 'heap' | 'special'
}
export function LeftSidebar() {
interface LeftSidebarProps {
onCollapse: () => void
}
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [isScanning, setIsScanning] = useState(false)
// Inline rename state for source-root rows. Stores the id being edited
@@ -674,6 +679,19 @@ export function LeftSidebar() {
return (
<div className="flex h-full flex-col bg-surface">
{/* Header with collapse button. Matches the right sidebar header
* so both panels have symmetric affordances. */}
<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">Library</h2>
<button
onClick={onCollapse}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
{/* Tree View */}
<div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))}

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">

View File

@@ -1,23 +1,59 @@
import { Settings } from 'lucide-react'
import { Settings, PanelLeftOpen, PanelRightOpen } from 'lucide-react'
import muliLogo from '../../assets/muli-logo.png'
interface TopBarProps {
onOpenSettings: () => void
leftSidebarOpen: boolean
rightSidebarOpen: boolean
onExpandLeft: () => void
onExpandRight: () => void
}
/**
* Slim top bar — logo on the left, settings gear on the right. The
* active heap badge moved into the Heaps panel in the left sidebar
* (where it actually relates to the heap rows the user navigates to).
*
* Also hosts the "expand sidebar" affordances: when a side panel is
* collapsed, a small panel-open icon appears on the corresponding edge
* so the user has a way to bring it back without hunting for the
* keyboard shortcut. When the panel is open, the button hides — its
* collapse twin lives in the panel's own header.
*/
export function TopBar({ onOpenSettings }: TopBarProps) {
export function TopBar({
onOpenSettings,
leftSidebarOpen,
rightSidebarOpen,
onExpandLeft,
onExpandRight,
}: TopBarProps) {
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
<div className="flex items-center gap-2">
{!leftSidebarOpen && (
<button
onClick={onExpandLeft}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Expand panel (Tab)"
aria-label="Expand left panel"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div>
<div className="flex items-center gap-2">
{!rightSidebarOpen && (
<button
onClick={onExpandRight}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Expand panel (I)"
aria-label="Expand right panel"
>
<PanelRightOpen className="h-4 w-4" />
</button>
)}
<button
onClick={onOpenSettings}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"

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 (