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:
@@ -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">
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-surface-offset"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setExpanded((v) => !v)
|
||||||
|
}}
|
||||||
|
aria-label="Toggle heaps"
|
||||||
|
>
|
||||||
{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',
|
||||||
|
|||||||
@@ -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,68 +879,61 @@ 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
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
* every section regardless of which tree item is selected. */}
|
|
||||||
<DateFilterSection />
|
|
||||||
|
|
||||||
{/* Active heap card — pinned just below the Library header so
|
{/* Shared with me — folders shared by other users */}
|
||||||
* toasts (bottom-left fixed) can't cover it. Returns null when
|
{sharedFolders.length > 0 && (
|
||||||
* no heap is active, so the layout collapses cleanly. */}
|
<div className="mt-1">
|
||||||
<ActiveHeapCard />
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||||
|
Shared with me
|
||||||
{/* Tree View */}
|
|
||||||
<div className="flex-1 overflow-y-auto pb-2">
|
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
|
||||||
|
|
||||||
{/* Shared with me — folders shared by other users */}
|
|
||||||
{sharedFolders.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
|
||||||
Shared with me
|
|
||||||
</div>
|
|
||||||
{sharedFolders.map((sf) => {
|
|
||||||
const isSelected = currentSection === `folder-${sf.id}`
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sf.id}
|
|
||||||
className={cn(
|
|
||||||
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
|
||||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: '20px' }}
|
|
||||||
onClick={() =>
|
|
||||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Users
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 flex-shrink-0',
|
|
||||||
isSelected ? 'text-primary' : 'text-text-muted'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate" title={sf.name}>
|
|
||||||
{sf.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
|
||||||
{sf.owner_username}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
|
||||||
{sf.permission}
|
|
||||||
</span>
|
|
||||||
{sf.photo_count > 0 && (
|
|
||||||
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
|
||||||
{sf.photo_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
{sharedFolders.map((sf) => {
|
||||||
})}
|
const isSelected = currentSection === `folder-${sf.id}`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sf.id}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||||
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: '20px' }}
|
||||||
|
onClick={() =>
|
||||||
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Users
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 flex-shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-text-muted'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate" title={sf.name}>
|
||||||
|
{sf.name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
||||||
|
{sf.owner_username}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
||||||
|
{sf.permission}
|
||||||
|
</span>
|
||||||
|
{sf.photo_count > 0 && (
|
||||||
|
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||||
|
{sf.photo_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Heaps — nested inside the Library section; HeapsPanel owns
|
||||||
|
* its own eyebrow header + collapse state. */}
|
||||||
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HeapsPanel />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user