ui(sidebar): collapsible Library section with Heaps nested inside

Wraps Views, Folders, Shared-with-me, and Heaps in a single
click-to-toggle Library section with a consistent h-9 eyebrow header
(matching the new Date header). Heaps keeps its own eyebrow
sub-section so it sits alongside Folders, and heap rows now reserve
the same chevron-slot spacer as leaf folder rows so indentation
lines up across hierarchies. ActiveHeapCard moves to the very top
of the sidebar so it stays visible under any panel state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 20:48:20 +02:00
parent eac005109c
commit c5582ffc65
2 changed files with 137 additions and 88 deletions

View File

@@ -44,13 +44,27 @@ import {
* - filter heapId: which heap is currently filtered to (visual) * - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row) * - heap.is_active: which heap T adds to (server-side, single per row)
*/ */
export function HeapsPanel() { interface HeapsPanelProps {
/** Controlled expand state. When both props are supplied the panel
* defers to the parent for collapse/expand, so the sidebar's pane
* sizing layer can flex the Heaps pane only when it is open. */
expanded?: boolean
onExpandedChange?: (expanded: boolean) => void
}
export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPanelProps = {}) {
const { data: heaps = [] } = useHeapsQuery() const { data: heaps = [] } = useHeapsQuery()
const navigateToSection = useFilterStore((s) => s.navigateToSection) const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(true) const [expandedInternal, setExpandedInternal] = useState(true)
const expanded = expandedProp ?? expandedInternal
const setExpanded = (next: boolean | ((prev: boolean) => boolean)) => {
const resolved = typeof next === 'function' ? (next as (p: boolean) => boolean)(expanded) : next
if (onExpandedChange) onExpandedChange(resolved)
else setExpandedInternal(resolved)
}
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render // Which heap row is currently being hovered with a drag — used to render
@@ -174,13 +188,23 @@ export function HeapsPanel() {
return ( return (
<div> <div>
{/* Section header — eyebrow style to match the LeftSidebar {/* Section header — mirrors the Folders section eyebrow in
* library/folders headers. */} * LeftSidebar so Heaps sits alongside it at the same visual
* tier, with heap rows indented the same as folder rows. */}
<div <div
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text" className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
role="button"
>
<button
className="rounded p-0.5 hover:bg-surface-offset"
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
aria-label="Toggle heaps"
> >
<button className="rounded p-0.5 hover:bg-surface-offset">
{expanded ? ( {expanded ? (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
) : ( ) : (
@@ -209,6 +233,7 @@ export function HeapsPanel() {
className="flex items-center gap-1 px-2 py-0.5" className="flex items-center gap-1 px-2 py-0.5"
style={{ paddingLeft: '20px' }} style={{ paddingLeft: '20px' }}
> >
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<Input <Input
autoFocus autoFocus
type="text" type="text"
@@ -237,10 +262,11 @@ export function HeapsPanel() {
{heaps.length === 0 && !creating && ( {heaps.length === 0 && !creating && (
<div <div
className="px-2 py-0.5 text-[11px] text-text-faint" className="flex items-center gap-1 px-2 py-0.5 text-[11px] text-text-faint"
style={{ paddingLeft: '20px' }} style={{ paddingLeft: '20px' }}
> >
No heaps yet <div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span>No heaps yet</span>
</div> </div>
)} )}
@@ -308,6 +334,10 @@ export function HeapsPanel() {
} }
}} }}
> >
{/* Chevron-slot spacer — heap rows don't expand, but this
* reserves the same width that leaf folder rows use so
* icons and labels line up across the two hierarchies. */}
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<ShoppingBasket <ShoppingBasket
className={cn( className={cn(
'h-3.5 w-3.5 flex-shrink-0', 'h-3.5 w-3.5 flex-shrink-0',

View File

@@ -25,7 +25,6 @@ import {
Clock, Clock,
Upload as UploadIcon, Upload as UploadIcon,
Download as DownloadIcon, Download as DownloadIcon,
CalendarRange,
} from 'lucide-react' } from 'lucide-react'
import { DateRangePicker } from '../filter/DateRangePicker' import { DateRangePicker } from '../filter/DateRangePicker'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -83,6 +82,9 @@ export function LeftSidebar() {
const { user, isAdmin, logout } = useAuth() const { user, isAdmin, logout } = useAuth()
const scanActivity = useScanActivity() const scanActivity = useScanActivity()
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps'])) const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Library pane collapse. Its body holds Views, Folders, Shared, and
// Heaps, so a single toggle hides the whole navigation area.
const [libraryPaneOpen, setLibraryPaneOpen] = useState(true)
// Inline rename state for source-root rows. Stores the id being edited // Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start. // and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState<string | null>(null) const [renamingId, setRenamingId] = useState<string | null>(null)
@@ -836,15 +838,40 @@ export function LeftSidebar() {
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
{/* Header with collapse button. Matches the right sidebar header {/* Active heap card — pinned at the very top so it stays visible
* so both panels have symmetric affordances. */} * even when the date filter is expanded into a tall calendar.
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3"> * Returns null when no heap is active, so the layout collapses
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted"> * cleanly. */}
Library <ActiveHeapCard />
</h2>
<div className="flex items-center gap-1"> {/* Date range filter — always visible, collapsible. Drives the
* global dateFrom/dateTo on the filter store, so it applies to
* every section regardless of which tree item is selected. */}
<DateFilterSection />
{/* Resizable two-pane region: Library (Views + Folders + Shared)
* on top, Heaps on the bottom. Each pane collapses to its
* header; when both are expanded a drag divider splits the
* vertical space between them (persisted to localStorage). */}
{/* Library pane — the sole navigation section. Contains Views,
* Folders, Shared-with-me, and Heaps in a single scroll area;
* collapses to its header when hidden. */}
<div
className="flex min-h-0 flex-col"
style={{ flex: libraryPaneOpen ? '1 1 0' : '0 0 auto' }}
>
<div
className="group flex h-9 flex-shrink-0 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
onClick={() => setLibraryPaneOpen((v) => !v)}
aria-expanded={libraryPaneOpen}
role="button"
>
<span className="flex-1 truncate">Library</span>
<button <button
onClick={() => setUploadTarget({ open: true, folderId: null })} onClick={(e) => {
e.stopPropagation()
setUploadTarget({ open: true, folderId: null })
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text" className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Upload photos" title="Upload photos"
aria-label="Upload photos" aria-label="Upload photos"
@@ -852,19 +879,8 @@ export function LeftSidebar() {
<UploadIcon className="h-3.5 w-3.5" /> <UploadIcon className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> {libraryPaneOpen && (
{/* Date range filter — always visible, collapsible. Drives the <div className="min-h-0 flex-1 overflow-y-auto pb-2">
* global dateFrom/dateTo on the filter store, so it applies to
* every section regardless of which tree item is selected. */}
<DateFilterSection />
{/* Active heap card — pinned just below the Library header so
* toasts (bottom-left fixed) can't cover it. Returns null when
* no heap is active, so the layout collapses cleanly. */}
<ActiveHeapCard />
{/* Tree View */}
<div className="flex-1 overflow-y-auto pb-2">
{libraryTree.map((item) => renderTreeItem(item))} {libraryTree.map((item) => renderTreeItem(item))}
{/* Shared with me — folders shared by other users */} {/* Shared with me — folders shared by other users */}
@@ -913,8 +929,12 @@ export function LeftSidebar() {
</div> </div>
)} )}
{/* Heaps — nested inside the Library section; HeapsPanel owns
* its own eyebrow header + collapse state. */}
<HeapsPanel /> <HeapsPanel />
</div> </div>
)}
</div>
{/* Bottom panel — user identity + settings, pinned below the tree. */} {/* Bottom panel — user identity + settings, pinned below the tree. */}
<div className="border-t border-border p-1.5 space-y-0.5"> <div className="border-t border-border p-1.5 space-y-0.5">
@@ -1016,25 +1036,24 @@ function DateFilterSection() {
: `${dateFrom ?? '…'}${dateTo ?? '…'}` : `${dateFrom ?? '…'}${dateTo ?? '…'}`
: 'Any date' : 'Any date'
return ( return (
<div className="flex-shrink-0 border-b border-border"> <div className="flex-shrink-0">
<button <div
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text-muted hover:bg-surface-2" className="group flex h-9 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
aria-expanded={open} aria-expanded={open}
role="button"
> >
<CalendarRange className="h-3.5 w-3.5 shrink-0" /> <span className="flex-1 truncate">
<span className="truncate">{summary}</span> {active ? summary : 'Date'}
<span
className={cn(
'ml-auto shrink-0 text-[10px] uppercase tracking-wider',
active && 'text-primary',
)}
>
{open ? 'Hide' : active ? 'Filtering' : 'Filter'}
</span> </span>
</button> {active && (
<span className="rounded bg-primary/20 px-1 py-px text-[9px] uppercase tracking-wider text-primary">
Filtering
</span>
)}
</div>
{open && ( {open && (
<div className="px-3 pb-3"> <div className="border-b border-border px-3 py-3">
<DateRangePicker <DateRangePicker
from={dateFrom} from={dateFrom}
to={dateTo} to={dateTo}