diff --git a/frontend/src/components/filter/DateRangePicker.tsx b/frontend/src/components/filter/DateRangePicker.tsx
index 90c32b3..97df32f 100644
--- a/frontend/src/components/filter/DateRangePicker.tsx
+++ b/frontend/src/components/filter/DateRangePicker.tsx
@@ -1,21 +1,16 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Filter, X } from 'lucide-react'
+import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { DayPicker } from 'react-day-picker'
import { useVirtualizer } from '@tanstack/react-virtual'
import { cn } from '@/lib/utils'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { usePhotoStore } from '../../store/photoStore'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover'
import { buttonVariants } from '@/components/ui/button'
// Fixed month height so snap points are uniform regardless of whether
// a given month lays out as 5 or 6 weeks. Sized to fit the tallest
-// (6-week) case: caption ~18px + weekday head ~14px + 6 rows × 24px +
-// padding ~12px.
+// (6-week) case: caption ~20px + weekday head ~14px + 6 rows × 24px
+// + 5 × 2px row gap = 154px. Plus a few px slack so the last row
+// never gets clipped on months that span 6 weeks (e.g. Nov 2025).
const MONTH_HEIGHT = 200
/**
@@ -214,9 +209,9 @@ export function DateRangePicker({
const next = photosByDate[lo]
const best =
!prev ? next
- : !next ? prev
- : target - prev.t <= next.t - target ? prev
- : next
+ : !next ? prev
+ : target - prev.t <= next.t - target ? prev
+ : next
if (best) jumpToPhoto(best.id)
},
[photosByDate, jumpToPhoto]
@@ -224,14 +219,8 @@ export function DateRangePicker({
return (
-
-
- {hasSelection && (
+ {hasSelection && (
+
- )}
-
+
+ )}
{/* Single-month viewport — container height matches exactly one
* month so the previous / next months stay off-screen. Uses
* scroll-snap so a wheel tick or drag settles on a whole month
@@ -256,7 +245,7 @@ export function DateRangePicker({
* sets scrollTop even when overflow is hidden. */}
-
-
-
-
- )
-}
-
function isInRange(d: Date, from?: Date, to?: Date): boolean {
if (!from && !to) return false
const t = d.getTime()
diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx
index ff54b1e..e37c1c4 100644
--- a/frontend/src/components/filter/FilterBar.tsx
+++ b/frontend/src/components/filter/FilterBar.tsx
@@ -77,6 +77,10 @@ export function FilterBar({
const tagIds = useFilterStore((s) => s.tagIds)
const needsReview = useFilterStore((s) => s.needsReview)
const setNeedsReview = useFilterStore((s) => s.setNeedsReview)
+ const dateFrom = useFilterStore((s) => s.dateFrom)
+ const dateTo = useFilterStore((s) => s.dateTo)
+ const setDateFrom = useFilterStore((s) => s.setDateFrom)
+ const setDateTo = useFilterStore((s) => s.setDateTo)
const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has
@@ -173,8 +177,48 @@ export function FilterBar({
{/* Pills — left side, scroll horizontally if they overflow. */}
- {/* Date filter is rendered inline at the top of the right
- * sidebar (always visible) — no pill needed here. */}
+ {/* Date */}
+ {
+ setDateFrom(null)
+ setDateTo(null)
+ }}
+ contentClassName="w-56 space-y-2 p-3"
+ >
+
+
+
{/* Type */}
+
+ Active Heap
+
{/* Header — clickable, navigates to the heap section. */}
+ >
)
}
diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx
index 32e70d2..de16b00 100644
--- a/frontend/src/components/heaps/HeapsPanel.tsx
+++ b/frontend/src/components/heaps/HeapsPanel.tsx
@@ -44,27 +44,13 @@ import {
* - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row)
*/
-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 = {}) {
+export function HeapsPanel() {
const { data: heaps = [] } = useHeapsQuery()
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient()
- 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 [expanded, setExpanded] = useState(true)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render
@@ -188,9 +174,9 @@ export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPa
return (
- {/* Section header — mirrors the Folders section eyebrow in
- * LeftSidebar so Heaps sits alongside it at the same visual
- * tier, with heap rows indented the same as folder rows. */}
+ {/* Section header — mirrors the Views/Folders section eyebrows
+ * in LeftSidebar so Heaps sits alongside them at the same
+ * visual tier, including the click-to-collapse chevron. */}
)}
{/* Shared with me */}
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 17cf3d4..c5ecae7 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
@@ -33,7 +33,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
-import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
@@ -84,7 +83,6 @@ export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState>(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
// and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState(null)
@@ -838,40 +836,18 @@ export function LeftSidebar() {
return (
- {/* Active heap card — pinned at the very top so it stays visible
- * even when the date filter is expanded into a tall calendar.
- * Returns null when no heap is active, so the layout collapses
- * cleanly. */}
-
-
- {/* 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. */}
+ {/* Date range calendar — always visible, collapsible. The
+ * from/to range inputs live on the top filter bar; this block
+ * just hosts the calendar visualisation and click-to-jump. */}
- {/* 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. */}
-
-
setLibraryPaneOpen((v) => !v)}
- aria-expanded={libraryPaneOpen}
- role="button"
- >
+ {/* Library — the sole navigation section. Contains Views,
+ * Folders, Shared-with-me, and Heaps in a single scroll area. */}
+
+
Library
- {libraryPaneOpen && (
-
+
{libraryTree.map((item) => renderTreeItem(item))}
{/* Shared with me — folders shared by other users */}
@@ -929,11 +904,9 @@ export function LeftSidebar() {
)}
- {/* Heaps — nested inside the Library section; HeapsPanel owns
- * its own eyebrow header + collapse state. */}
+ {/* Heaps — nested inside the Library section. */}
-
- )}
+
{/* Bottom panel — user identity + settings, pinned below the tree. */}
@@ -999,69 +972,22 @@ export function LeftSidebar() {
)
}
-/** Collapsible date-range filter block. Lives at the top of the left
- * sidebar and drives the global dateFrom/dateTo filter-store fields,
- * so it applies across every section. The calendar component itself
- * supports range selection and decorates days that have photos.
- * Open state persists across sessions via localStorage. */
-const DATE_FILTER_OPEN_KEY = 'mulita:dateFilterOpen'
+/** Always-visible calendar block at the top of the left sidebar.
+ * Drives the same global dateFrom/dateTo filter-store fields as the
+ * topbar Date pill, and visualises photo density per day. */
function DateFilterSection() {
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
- const active = dateFrom !== null || dateTo !== null
- // Default open; remembered across sessions. localStorage is read
- // lazily inside the initialiser so SSR / disabled-storage fall back
- // cleanly to the default.
- const [open, setOpen] = useState(() => {
- try {
- const v = localStorage.getItem(DATE_FILTER_OPEN_KEY)
- if (v === null) return true
- return v === '1'
- } catch {
- return true
- }
- })
- useEffect(() => {
- try {
- localStorage.setItem(DATE_FILTER_OPEN_KEY, open ? '1' : '0')
- } catch {
- // Ignore — quota / disabled storage is non-fatal.
- }
- }, [open])
- const summary = active
- ? dateFrom && dateTo && dateFrom === dateTo
- ? dateFrom
- : `${dateFrom ?? '…'} → ${dateTo ?? '…'}`
- : 'Any date'
return (
-