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:
@@ -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))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user