From d6c667ae785112163cb3448d165868dbf416bf0c Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Apr 2026 16:20:22 +0200 Subject: [PATCH] feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/App.tsx | 32 +++----- frontend/src/components/KeyboardHints.tsx | 5 +- .../src/components/layout/LeftSidebar.tsx | 20 ++++- .../src/components/layout/RightSidebar.tsx | 80 ++++++++++++------- frontend/src/components/layout/TopBar.tsx | 40 +++++++++- frontend/src/components/timeline/Timeline.tsx | 73 +++++++++++++++-- 6 files changed, 189 insertions(+), 61 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e51c5da..6ef9f55 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (
- setSettingsOpen(true)} /> + setSettingsOpen(true)} + leftSidebarOpen={leftSidebarOpen} + rightSidebarOpen={showRightSidebar} + onExpandLeft={() => setLeftSidebarOpen(true)} + onExpandRight={() => setRightSidebarOpen(true)} + />
{/* Left Sidebar */} @@ -64,7 +58,7 @@ function App() { leftSidebarOpen ? 'w-64' : 'w-0' } overflow-hidden border-r border-border bg-surface`} > - + setLeftSidebarOpen(false)} />
{/* 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`} > - + setRightSidebarOpen(false)} />
diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index 7cc9426..8b16f52 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -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' }, ] diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 358b5c4..5caa97e 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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>(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 (
+ {/* Header with collapse button. Matches the right sidebar header + * so both panels have symmetric affordances. */} +
+

Library

+ +
{/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))} diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 8b7c4d5..c40f1dc 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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 = () => ( +
+

{headerTitle}

+
+ {selectedPhotos.length > 0 && ( + + )} + +
+
+ ) + if (selectedPhotos.length === 0) { return ( -
-
- -

Select photos to view details

+
+
+
+
+ +

Select photos to view details

+
) @@ -150,17 +193,7 @@ export function RightSidebar() { const id = activePhotoId ?? selectedPhotos[0] return (
-
-

Metadata

- -
+
) @@ -171,18 +204,7 @@ export function RightSidebar() { return (
-
-

- {selectedPhotos.length} Photos Selected -

- -
+

diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index eeaf1c5..82e1b62 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -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 (

+ {!leftSidebarOpen && ( + + )} Mulimago

Mulimago

+ {!rightSidebarOpen && ( + + )}