feat(filters): move date picker to sidebar, track visible photo order

Pulls the date range picker out of the filter-bar pill into a
dedicated always-visible section at the top of the left sidebar, and
teaches the timeline to publish its visible photo sequence so "open
first photo" shortcuts respect the on-screen order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 17:12:34 +02:00
parent 45f1649979
commit eac005109c
8 changed files with 764 additions and 184 deletions

View File

@@ -76,11 +76,18 @@ function MainApp() {
// of what filter key it's stored under.
const { data: allPhotos } = usePhotosQuery()
// Set up global keyboard shortcuts
// Set up global keyboard shortcuts. Prefer the Timeline's published
// visible sequence (which respects per-month ordering) over the raw
// backend list — otherwise "Space on a blank selection" would open
// the globally first photo, which isn't what the user sees at the
// top-left of the grid.
useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: toggleRightSidebar,
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
getFirstPhotoId: () =>
usePhotoStore.getState().visiblePhotoIds[0] ??
allPhotos?.[0]?.id ??
null,
})
// Settings page is a full-page section — hide filter bar, right sidebar,

View File

@@ -0,0 +1,489 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Filter, X } from 'lucide-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.
const MONTH_HEIGHT = 200
/**
* Continuous vertical calendar. Renders one DayPicker per month from
* the earliest booked year up to next month, stacked in a scrollable
* column. As the user scrolls the Timeline, the calendar
* programmatically scrolls the matching month into view — so the date
* picker is always showing "what the user is looking at."
*
* Clicking days does nothing here (no range selection); range setting
* lives in the popover reachable via the Filter button.
*/
export function DateRangePicker({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
const { data: photos = [] } = usePhotosQuery()
// Photo count per yyyy-mm-dd — drives a GitHub-contribution-style
// intensity heatmap on the calendar. Slicing the ISO string verbatim
// avoids timezone drift (Date round-trips would shift photos near
// midnight into the adjacent day).
const countByDay = useMemo(() => {
const m = new Map<string, number>()
for (const p of photos) {
if (!p.taken_at) continue
const key = p.taken_at.slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue
m.set(key, (m.get(key) ?? 0) + 1)
}
return m
}, [photos])
// Thresholds picked from the count distribution so the heatmap adapts
// to the library: a 100k-photo archive and a 200-photo album both get
// meaningful contrast. Quartiles of non-zero day counts, with a
// floor so a very flat distribution still buckets into 4 steps.
const levelThresholds = useMemo(() => {
const counts = Array.from(countByDay.values()).sort((a, b) => a - b)
if (counts.length === 0) return [1, 2, 4, 8]
const q = (p: number) =>
counts[Math.min(counts.length - 1, Math.floor(counts.length * p))]
// 4 buckets: [≥1, ≥q50, ≥q75, ≥q95]
const t1 = 1
const t2 = Math.max(2, q(0.5))
const t3 = Math.max(t2 + 1, q(0.75))
const t4 = Math.max(t3 + 1, q(0.95))
return [t1, t2, t3, t4]
}, [countByDay])
// Oldest year in the cache — drives the top of the month list.
const currentDate = new Date()
const earliestYear = useMemo(() => {
let min = currentDate.getFullYear()
for (const k of countByDay.keys()) {
const y = parseInt(k.slice(0, 4), 10)
if (!Number.isNaN(y) && y < min) min = y
}
return min
}, [countByDay, currentDate])
// Flat list of yyyy-mm keys from earliest year's January up through
// (and including) next month, newest first — matches the default
// timeline sort order (desc), so scroll direction feels consistent.
const monthKeys = useMemo(() => {
const end = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
const start = new Date(earliestYear, 0, 1)
const out: string[] = []
const cursor = new Date(end)
while (cursor >= start) {
const y = cursor.getFullYear()
const m = String(cursor.getMonth() + 1).padStart(2, '0')
out.push(`${y}-${m}`)
cursor.setMonth(cursor.getMonth() - 1)
}
return out
}, [earliestYear, currentDate])
// O(1) id→taken_at map so activePhotoId lookups don't linear-scan
// the whole library on every selection change.
const takenAtById = useMemo(() => {
const m = new Map<string, string | null | undefined>()
for (const p of photos) m.set(p.id, p.taken_at)
return m
}, [photos])
// Month the calendar should currently centre on. Priority:
// 1. The currently focused photo's taken_at (follows click/arrow
// navigation — lets the calendar land on the right month even
// before any scroll has happened).
// 2. The Timeline's published anchor date (live scroll tracking).
// 3. A picked from/to bound (so opening the picker with a saved
// filter doesn't snap back to today).
// 4. Current month, as a final fallback.
const visibleAnchorDate = usePhotoStore((s) => s.visibleAnchorDate)
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const activePhotoDate = useMemo(() => {
if (!activePhotoId) return null
const iso = takenAtById.get(activePhotoId)?.slice(0, 10)
return iso && /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null
}, [activePhotoId, takenAtById])
const anchorMonthKey = useMemo(() => {
const src = activePhotoDate ?? visibleAnchorDate ?? from ?? to
if (src && /^\d{4}-\d{2}/.test(src)) return src.slice(0, 7)
return `${currentDate.getFullYear()}-${String(
currentDate.getMonth() + 1,
).padStart(2, '0')}`
}, [activePhotoDate, visibleAnchorDate, from, to, currentDate])
// Index-in-monthKeys of the anchor month — needed both to drive the
// virtualizer's scrollToIndex and as the O(1) lookup for the month
// list below (avoids a linear indexOf per render).
const monthIndex = useMemo(() => {
const m = new Map<string, number>()
for (let i = 0; i < monthKeys.length; i++) m.set(monthKeys[i], i)
return m
}, [monthKeys])
// Virtualise the month list. Mounting 100+ DayPickers up-front was
// the dominant cost of opening the calendar; with the viewport fixed
// to MONTH_HEIGHT we only keep ~1 mounted at any moment (+overscan).
const scrollRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: monthKeys.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => MONTH_HEIGHT,
overscan: 1,
getItemKey: (idx) => monthKeys[idx],
})
// Jump instantly to the anchor on first mount; smooth-scroll for
// subsequent moves so a click-through-sidebar feels natural while
// the initial paint lands on the right month without animation.
//
// Only depend on `anchorMonthKey` — `virtualizer` gets a new object
// identity on most renders, so including it would refire this effect
// constantly and fight the user's own wheel/drag scroll in the
// calendar viewport. We read the latest virtualizer via a ref.
const virtualizerRef = useRef(virtualizer)
virtualizerRef.current = virtualizer
const didInitialScrollRef = useRef(false)
useEffect(() => {
const idx = monthIndex.get(anchorMonthKey)
if (idx === undefined) return
virtualizerRef.current.scrollToIndex(idx, {
align: 'start',
behavior: didInitialScrollRef.current ? 'smooth' : 'auto',
})
didInitialScrollRef.current = true
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchorMonthKey, monthIndex])
const hasSelection = !!(from || to)
// Precomputed (time, id) array sorted ascending, so "closest photo
// to a clicked date" is an O(log n) binary search instead of a
// linear scan that allocates a Date per photo on every click.
const photosByDate = useMemo(() => {
const out: { t: number; id: string }[] = []
for (const p of photos) {
if (!p.taken_at) continue
const d = parseIsoDate(p.taken_at.slice(0, 10)).getTime()
if (!Number.isFinite(d)) continue
out.push({ t: d, id: p.id })
}
out.sort((a, b) => a.t - b.t)
return out
}, [photos])
// Click a day → find the photo whose taken_at is closest and jump
// the timeline to it. Empty days still jump to the nearest neighbour
// so a click never no-ops.
const jumpToPhoto = usePhotoStore((s) => s.jumpToPhoto)
// Stable identity so MonthGrid's memo actually skips unchanged months.
const onDayClick = useCallback(
(iso: string) => {
if (photosByDate.length === 0) return
const target = parseIsoDate(iso).getTime()
// Binary search for the insertion point.
let lo = 0
let hi = photosByDate.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (photosByDate[mid].t < target) lo = mid + 1
else hi = mid
}
// Compare the two neighbours and pick whichever is closer.
const prev = photosByDate[lo - 1]
const next = photosByDate[lo]
const best =
!prev ? next
: !next ? prev
: target - prev.t <= next.t - target ? prev
: next
if (best) jumpToPhoto(best.id)
},
[photosByDate, jumpToPhoto]
)
return (
<div className="space-y-1.5">
<div className="flex items-center justify-end gap-1">
<DateFilterPopover
from={from}
to={to}
onFromChange={onFromChange}
onToChange={onToChange}
/>
{hasSelection && (
<button
onClick={() => {
onFromChange(null)
onToChange(null)
}}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-text-muted hover:bg-surface-2 hover:text-text"
>
Clear
</button>
)}
</div>
{/* 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
* rather than an awkward halfway position. Each month wrapper
* is fixed-height (padded to fit the 6-row calendar layout)
* so the snap points are evenly spaced. */}
{/* Virtualised single-month viewport. `overflow: hidden` keeps
* the user from scrolling the calendar manually — month-changes
* are always driven from outside (timeline scroll / active
* photo / filter bounds). Programmatic `scrollToIndex` still
* sets scrollTop even when overflow is hidden. */}
<div
ref={scrollRef}
className="overflow-hidden rounded border border-border bg-bg"
style={{ height: MONTH_HEIGHT }}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const k = monthKeys[vItem.index]
const [y, m] = k.split('-').map(Number)
return (
<div
key={k}
className="flex items-start justify-center px-1 py-1"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: MONTH_HEIGHT,
transform: `translateY(${vItem.start}px)`,
}}
>
<MonthGrid
year={y}
month={m - 1}
countByDay={countByDay}
levels={levelThresholds}
focusedIso={activePhotoDate}
fromIso={from}
toIso={to}
onDayClick={onDayClick}
/>
</div>
)
})}
</div>
</div>
</div>
)
}
/** Single month calendar — read-only, no nav buttons, no selection
* handler. Colours each day by photo count (GitHub-contributions
* style) and highlights any day inside the currently-filtered range. */
const MonthGrid = memo(function MonthGrid({
year,
month,
countByDay,
levels,
focusedIso,
fromIso,
toIso,
onDayClick,
}: {
year: number
month: number
countByDay: Map<string, number>
levels: number[]
focusedIso: string | null
fromIso: string | null
toIso: string | null
onDayClick: (iso: string) => void
}) {
const from = fromIso ? parseIsoDate(fromIso) : undefined
const to = toIso ? parseIsoDate(toIso) : undefined
const monthDate = new Date(year, month, 1)
const [t1, t2, t3, t4] = levels
const dayLevel = (d: Date): 0 | 1 | 2 | 3 | 4 => {
const n = countByDay.get(formatIsoDate(d)) ?? 0
if (n >= t4) return 4
if (n >= t3) return 3
if (n >= t2) return 2
if (n >= t1) return 1
return 0
}
return (
<DayPicker
mode="default"
month={monthDate}
disableNavigation
showOutsideDays={false}
className="p-1"
classNames={{
months: 'flex flex-col',
month: 'flex flex-col gap-1',
caption: 'flex items-center justify-center px-1 pt-0.5',
caption_label: 'text-[11px] font-semibold uppercase tracking-wide text-text',
nav: 'hidden',
table: 'w-full border-collapse',
head_row: 'flex',
head_cell:
'text-text-muted rounded w-6 font-normal text-[9px] uppercase tracking-wide',
row: 'flex w-full mt-0.5',
cell: 'relative p-0 text-center text-xs',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-6 w-6 p-0 text-xs font-normal cursor-pointer',
),
day_today: 'text-star ring-1 ring-star/60',
}}
modifiers={{
level1: (d) => dayLevel(d) === 1,
level2: (d) => dayLevel(d) === 2,
level3: (d) => dayLevel(d) === 3,
level4: (d) => dayLevel(d) === 4,
inRange: (d) => isInRange(d, from, to),
focused: (d) => !!focusedIso && formatIsoDate(d) === focusedIso,
}}
modifiersClassNames={{
// GitHub-contributions-style intensity shades. Each level tints
// the cell's background with an increasing primary opacity
// while keeping text readable.
level1: 'bg-primary/15 text-text',
level2: 'bg-primary/30 text-text',
level3: 'bg-primary/55 text-bg',
level4: 'bg-primary/85 text-bg font-semibold',
// Filter-range highlight composes on top with a subtle ring
// instead of a background so the intensity heatmap stays
// legible.
inRange: 'ring-1 ring-primary',
// Focused day — the taken_at of the currently active photo.
// Stronger ring + offset so it pops above the range highlight
// without fighting the heatmap fill underneath.
focused:
'ring-2 ring-star ring-offset-1 ring-offset-bg font-semibold',
}}
title="Daily photo count"
onDayClick={(d) => onDayClick(formatIsoDate(d))}
/>
)
})
/** Compact popover with two native date inputs for setting a range
* explicitly. Lives behind the "Filter" button next to the calendar. */
function DateFilterPopover({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
const [open, setOpen] = useState(false)
const active = !!(from || to)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wider',
active
? 'border-primary/60 bg-primary/10 text-primary'
: 'border-border text-text-muted hover:bg-surface-2 hover:text-text',
)}
>
<Filter className="h-3 w-3" />
Filter
</PopoverTrigger>
<PopoverContent className="w-56 space-y-2 p-3" side="bottom" align="end">
<div className="flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-text">
Date range
</h3>
{active && (
<button
onClick={() => {
onFromChange(null)
onToChange(null)
}}
className="flex items-center gap-0.5 rounded px-1 py-0.5 text-[10px] text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear range"
>
<X className="h-3 w-3" />
Clear
</button>
)}
</div>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
From
</span>
<input
type="date"
value={from ?? ''}
onChange={(e) => onFromChange(e.target.value || null)}
max={to ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
<label className="block space-y-0.5">
<span className="text-[10px] uppercase tracking-wider text-text-muted">
To
</span>
<input
type="date"
value={to ?? ''}
onChange={(e) => onToChange(e.target.value || null)}
min={from ?? undefined}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</label>
</PopoverContent>
</Popover>
)
}
function isInRange(d: Date, from?: Date, to?: Date): boolean {
if (!from && !to) return false
const t = d.getTime()
if (from && t < from.getTime()) return false
if (to && t > to.getTime()) return false
return true
}
function parseIsoDate(s: string): Date {
const [y, m, d] = s.split('-').map(Number)
return new Date(y, (m ?? 1) - 1, d ?? 1)
}
function formatIsoDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Star,
X,
@@ -18,7 +18,6 @@ import {
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Input } from '@/components/ui/input'
@@ -31,7 +30,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { MultiSelect } from '@/components/ui/multi-select'
import { Calendar } from '@/components/ui/calendar'
const SEARCH_DEBOUNCE_MS = 300
@@ -70,8 +68,6 @@ export function FilterBar({
onToggleRightSidebar,
}: FilterBarProps) {
const filterState = useFilterStore()
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const mediaTypes = useFilterStore((s) => s.mediaTypes)
const ratingMin = useFilterStore((s) => s.ratingMin)
const colorLabel = useFilterStore((s) => s.colorLabel)
@@ -90,8 +86,6 @@ export function FilterBar({
// them further (ratingMin >= 3, restrict to specific tag ids).
const hideFlagPill = currentSection === 'discarded'
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
const toggleMediaType = useFilterStore((s) => s.toggleMediaType)
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setColorLabel = useFilterStore((s) => s.setColorLabel)
@@ -124,11 +118,6 @@ export function FilterBar({
}, [searchQuery, storeQ, setStoreQ])
// Pre-compute pill values + active flags so the JSX stays terse.
const dateActive = dateFrom !== null || dateTo !== null
const dateValue = dateActive
? `${dateFrom ?? '…'}${dateTo ?? '…'}`
: null
const typeActive = mediaTypes.length > 0
const typeValue = typeActive
? mediaTypes.map((t) => t.toUpperCase()).join(', ')
@@ -184,23 +173,8 @@ export function FilterBar({
{/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<DateRangePicker
from={dateFrom}
to={dateTo}
onFromChange={setDateFrom}
onToChange={setDateTo}
/>
</FilterPill>
{/* Date filter is rendered inline at the top of the right
* sidebar (always visible) — no pill needed here. */}
{/* Type */}
<FilterPill
@@ -486,111 +460,3 @@ export function FilterBar({
)
}
/** Date range picker used inside the Date FilterPill. A single
* shadcn/react-day-picker Calendar in `range` mode — first click picks
* the start, second click picks the end. Writes both bounds to the
* store as yyyy-mm-dd ISO strings.
*
* Days without matching photos are disabled + visually dimmed
* ("booked days" pattern from shadcn docs), so the user can see at a
* glance which dates are worth clicking. The booked set is derived
* from the currently-cached photos — reflects every other active
* filter, which is the intended UX: "which days have 5★ photos of
* screenshots" etc. The caption uses the dropdown layout so the user
* can jump across months/years without clicking the arrows. */
function DateRangePicker({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
// Unique yyyy-mm-dd keys of every cached photo's taken_at. Derived
// from the shared photos cache so opening the calendar doesn't fire
// another network request.
const { data: photos = [] } = usePhotosQuery()
const bookedSet = useMemo(() => {
const s = new Set<string>()
for (const p of photos) {
if (!p.taken_at) continue
const d = new Date(p.taken_at)
if (Number.isNaN(d.getTime())) continue
s.add(formatIsoDate(d))
}
return s
}, [photos])
const currentYear = new Date().getFullYear()
const earliestYear = useMemo(() => {
let min = currentYear
for (const k of bookedSet) {
const y = parseInt(k.slice(0, 4), 10)
if (!Number.isNaN(y) && y < min) min = y
}
return min
}, [bookedSet, currentYear])
const selected = {
from: from ? parseIsoDate(from) : undefined,
to: to ? parseIsoDate(to) : undefined,
}
const handleSelect = (range: { from?: Date; to?: Date } | undefined) => {
onFromChange(range?.from ? formatIsoDate(range.from) : null)
onToChange(range?.to ? formatIsoDate(range.to) : null)
}
const label =
from && to
? from === to
? from
: `${from}${to}`
: from
? `From ${from}`
: to
? `Until ${to}`
: 'Click to pick a start date, then an end date'
return (
<div className="space-y-2">
<div className="text-[11px] text-text-muted">{label}</div>
<Calendar
mode="range"
selected={selected}
onSelect={handleSelect}
numberOfMonths={1}
defaultMonth={selected.from ?? selected.to ?? new Date()}
captionLayout="dropdown"
fromYear={earliestYear}
toYear={currentYear + 1}
modifiers={{
booked: (d) => bookedSet.has(formatIsoDate(d)),
}}
modifiersClassNames={{
// Days that DO have matching photos get a small primary dot
// below the number; unmatched days stay visible and
// clickable so the user can still pick any bound they want.
booked:
'relative font-semibold text-text after:absolute after:bottom-0.5 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary after:content-[""]',
}}
className="rounded-md border border-border bg-bg p-2"
/>
</div>
)
}
function parseIsoDate(s: string): Date {
// yyyy-mm-dd → Date with local components so a user's "2024-01-15"
// never rolls back to 2024-01-14 in a pacific timezone.
const [y, m, d] = s.split('-').map(Number)
return new Date(y, (m ?? 1) - 1, d ?? 1)
}
function formatIsoDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
ChevronRight,
ChevronDown,
@@ -25,7 +25,9 @@ import {
Clock,
Upload as UploadIcon,
Download as DownloadIcon,
CalendarRange,
} from 'lucide-react'
import { DateRangePicker } from '../filter/DateRangePicker'
import { cn } from '@/lib/utils'
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -851,6 +853,11 @@ export function LeftSidebar() {
</button>
</div>
</div>
{/* 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 />
{/* 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. */}
@@ -972,3 +979,71 @@ 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'
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<boolean>(() => {
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 (
<div className="flex-shrink-0 border-b border-border">
<button
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"
aria-expanded={open}
>
<CalendarRange className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{summary}</span>
<span
className={cn(
'ml-auto shrink-0 text-[10px] uppercase tracking-wider',
active && 'text-primary',
)}
>
{open ? 'Hide' : active ? 'Filtering' : 'Filter'}
</span>
</button>
{open && (
<div className="px-3 pb-3">
<DateRangePicker
from={dateFrom}
to={dateTo}
onFromChange={setDateFrom}
onToChange={setDateTo}
/>
</div>
)}
</div>
)
}

View File

@@ -213,6 +213,7 @@ export function RightSidebar() {
</div>
)
if (selectedPhotos.length === 0) {
return (
<div

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import { format } from 'date-fns'
import { cn } from '@/lib/utils'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
@@ -91,6 +91,23 @@ function buildItems(
label: currentLabel,
height: HEADER_HEIGHT,
})
// Sort the current month's photos ascending (day 1 → day 31) so
// each month reads chronologically start-to-end regardless of the
// global sortOrder. Month buckets themselves stay in the order the
// backend returned them (honours `sortOrder` for the month-level
// axis). Stable on equal timestamps via the globalIndex tiebreak.
bucket.sort((a, b) => {
const aKey =
(sortBy === 'taken_at'
? a.photo.taken_at
: a.photo.added_at ?? a.photo.taken_at) ?? ''
const bKey =
(sortBy === 'taken_at'
? b.photo.taken_at
: b.photo.added_at ?? b.photo.taken_at) ?? ''
if (aKey === bKey) return a.globalIndex - b.globalIndex
return aKey < bKey ? -1 : 1
})
pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
bucketIndex++
bucket = []
@@ -101,9 +118,21 @@ function buildItems(
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
let label: string
if (dateStr) {
try {
label = format(parseISO(dateStr), 'MMMM yyyy')
} catch {
// Same slice-first strategy the scroll-date chip uses — the
// date portion is trusted verbatim so local timezones can't
// drag an early/late-day photo into the wrong month bucket.
const iso = dateStr.slice(0, 10)
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
if (match) {
const y = Number(match[1])
const mth = Number(match[2])
const day = Number(match[3])
try {
label = format(new Date(y, mth - 1, day), 'MMMM yyyy')
} catch {
label = 'Unknown date'
}
} else {
label = 'Unknown date'
}
} else {
@@ -180,6 +209,12 @@ export function Timeline() {
// explicitly clears the selection (Escape), we don't re-focus, so
// the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
// visibleSequence isn't in scope yet (derived below from items). Read
// it through a ref so this effect can focus the first *visually*
// ordered photo — matters now that months sort ascending while the
// photos array from the backend is still globally desc, i.e. the
// top-left cell the user actually sees isn't photos[0] anymore.
const visibleSequenceRef = useRef<string[]>([])
useEffect(() => {
if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return
@@ -187,9 +222,10 @@ export function Timeline() {
didAutoFocusRef.current = true
return
}
if (photos.length === 0) return
const firstVisible = visibleSequenceRef.current[0]
if (!firstVisible) return
didAutoFocusRef.current = true
selectPhoto(photos[0].id)
selectPhoto(firstVisible)
}, [viewMode, activePhotoId, photos, selectPhoto])
// Membership in the active heap (drives the green tint on each
@@ -200,9 +236,8 @@ export function Timeline() {
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
// identity-stable callbacks let it skip re-render on unrelated store
// churn (e.g. heap membership invalidation). visibleSequence is read
// through a ref at click time so scrolling doesn't rebind the
// double-click handler.
const visibleSequenceRef = useRef<string[]>([])
// through the ref declared above at click time so scrolling doesn't
// rebind the double-click handler.
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
@@ -251,14 +286,21 @@ export function Timeline() {
const headerOffsets = useMemo(() => {
const result: { offset: number; label: string }[] = []
let cumulative = 0
// Row items carry a placeholder height of 0 (see buildItems) so
// that the items array stays stable across cellSize changes. We
// resolve it here against the live cellSize so header offsets line
// up with the virtualizer's coordinate space.
const rowH = cellSize + GAP
for (const item of items) {
if (item.type === 'header') {
result.push({ offset: cumulative, label: item.label })
cumulative += item.height
} else {
cumulative += rowH
}
cumulative += item.height
}
return result
}, [items])
}, [items, cellSize])
// Virtual scrolling setup with per-item heights.
const virtualizer = useVirtualizer({
@@ -352,6 +394,12 @@ export function Timeline() {
if (!isDateSort) return [] as { offset: number; raw: string | null }[]
const out: { offset: number; raw: string | null }[] = []
let cum = 0
// Row items carry a placeholder `height: 0` (resolved at render
// time from cellSize via effectiveHeight). Sum the live height here
// so cumulative offsets match the virtualizer's coordinate space —
// otherwise every row lands at offset 0 and the scroll chip /
// sidebar calendar freeze on the last photo regardless of scroll.
const rowH = cellSize + GAP
for (const item of items) {
if (item.type === 'row') {
const first = item.cells[0]?.photo
@@ -360,11 +408,13 @@ export function Timeline() {
? first?.added_at ?? first?.taken_at ?? null
: first?.taken_at ?? null
out.push({ offset: cum, raw })
cum += rowH
} else {
cum += item.height
}
cum += item.height
}
return out
}, [items, sortBy, isDateSort])
}, [items, sortBy, isDateSort, cellSize])
// Date shown in the floating chip next to the scrollbar. Finds the
// deepest row whose offset is at/above the viewport top (plus a
@@ -372,11 +422,10 @@ export function Timeline() {
// photo's capture date. Null when not date-sorted, not scrolling, or
// when the topmost row has no date — we'd rather hide the chip than
// show an unhelpful "Unknown".
const scrollDateLabel = useMemo(() => {
const scrollRaw = useMemo(() => {
if (!isDateSort || rowDateIndex.length === 0) return null
const target = scrollMetrics.top + 20
let raw: string | null = null
// Binary search: largest offset ≤ target.
let lo = 0
let hi = rowDateIndex.length - 1
while (lo <= hi) {
@@ -388,13 +437,36 @@ export function Timeline() {
hi = mid - 1
}
}
if (!raw) return null
return raw
}, [rowDateIndex, scrollMetrics.top, isDateSort])
const scrollDateLabel = useMemo(() => {
if (!scrollRaw) return null
// Take the date portion straight from the ISO string and build a
// local-midnight Date for formatting — avoids parseISO's timezone
// conversion shifting the displayed day.
const iso = scrollRaw.slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null
const [y, m, d] = iso.split('-').map(Number)
try {
return format(parseISO(raw), 'MMM d, yyyy')
return format(new Date(y, m - 1, d), 'MMM d, yyyy')
} catch {
return null
}
}, [rowDateIndex, scrollMetrics.top, isDateSort])
}, [scrollRaw])
// Publish the anchor date (yyyy-mm-dd) so the left-sidebar Date
// picker can track which month the user is scrolled to. The leading
// 10 chars of the ISO string are taken verbatim — no Date round-trip
// — so timezones can't shift the visible day by one.
const setVisibleAnchorDate = usePhotoStore((s) => s.setVisibleAnchorDate)
useEffect(() => {
if (!scrollRaw) {
setVisibleAnchorDate(null)
return
}
const iso = scrollRaw.slice(0, 10)
setVisibleAnchorDate(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null)
}, [scrollRaw, setVisibleAnchorDate])
// Vertical position of the floating chip in scroll-viewport pixels.
// Tracks the scrollbar thumb's position by mapping scroll progress
@@ -507,6 +579,45 @@ export function Timeline() {
scrollEl.scrollTo({ top: target })
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
// Handle explicit jump requests from outside the grid (e.g. the
// sidebar calendar clicking a date). Same centring math as the
// preview-close path but uses smooth scroll so the jump is visually
// connected to the click, and always fires regardless of viewport
// state. Clears the target after scrolling so the next click on the
// same id still fires a fresh scroll.
const jumpTargetId = usePhotoStore((s) => s.jumpTargetId)
const clearJumpTarget = usePhotoStore((s) => s.clearJumpTarget)
useEffect(() => {
if (!jumpTargetId) return
if (viewMode !== 'grid') return
let rowIdx = -1
for (let r = 0; r < photoRows.length; r++) {
if (photoRows[r].cells.some((c) => c.photo.id === jumpTargetId)) {
rowIdx = r
break
}
}
if (rowIdx < 0) {
clearJumpTarget()
return
}
const itemIdx = photoRowItemIndex[rowIdx]
const scrollEl = parentRef.current
if (itemIdx === undefined || !scrollEl) {
clearJumpTarget()
return
}
let rowTop = 0
for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
const rowHeight = effectiveHeight(items[itemIdx])
const target = Math.max(
0,
rowTop - scrollEl.clientHeight / 2 + rowHeight / 2,
)
scrollEl.scrollTo({ top: target, behavior: 'smooth' })
clearJumpTarget()
}, [jumpTargetId, viewMode, photoRows, photoRowItemIndex, items, clearJumpTarget])
// Flat visible-order id sequence — exactly the order the user reads
// off the grid (top-to-bottom, left-to-right within each row).
// Includes duplicates from tag-grouping; landing on the same photo's
@@ -522,12 +633,17 @@ export function Timeline() {
return ids
}, [photoRows])
// Mirror visibleSequence into a ref synchronously so the auto-focus
// effect (which runs earlier and references this ref) sees the
// current sequence on the same render the data loads. Updating the
// ref inside an effect would lag by one render.
visibleSequenceRef.current = visibleSequence
// Publish to the photo store so PreviewView's arrow nav and filmstrip
// can walk the same order even when opened from a non-click path
// (e.g. the global Space hotkey).
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
visibleSequenceRef.current = visibleSequence
}, [visibleSequence, setVisiblePhotoIds])
// Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with

View File

@@ -16,48 +16,41 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
className={cn('p-1.5', className)}
classNames={{
months: 'flex flex-col sm:flex-row gap-4',
month: 'flex flex-col gap-3',
// Row layout: prev-nav · dropdowns (Month / Year) · next-nav.
// Works for both button and dropdown caption layouts.
caption: 'flex items-center justify-between px-1 pt-1',
caption_label: 'text-sm font-medium text-text',
// Dropdown caption — react-day-picker v8 renders each dropdown
// as an invisible <select> layered over a visible caption_label
// span. We position the <select> absolutely + transparent so
// the label is what's actually drawn, while clicks still open
// the native picker. The top-level CaptionLabel ("January 2026"
// in full) sits inside a .vhidden wrapper — absolutely clipped
// so it stays screen-reader only.
caption_dropdowns: 'flex items-center justify-center gap-2',
dropdown_month: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
dropdown_year: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
months: 'flex flex-col sm:flex-row gap-2',
month: 'flex flex-col gap-1.5',
caption: 'flex items-center justify-between px-0.5 pt-0.5',
caption_label: 'text-xs font-medium text-text',
caption_dropdowns: 'flex items-center justify-center gap-1.5',
dropdown_month:
'relative inline-flex items-center rounded px-0.5 text-xs font-medium text-text hover:bg-surface-2',
dropdown_year:
'relative inline-flex items-center rounded px-0.5 text-xs font-medium text-text hover:bg-surface-2',
dropdown:
'absolute inset-0 z-10 cursor-pointer appearance-none bg-transparent opacity-0',
dropdown_icon: 'ml-1 h-3 w-3 opacity-60',
dropdown_icon: 'ml-0.5 h-3 w-3 opacity-60',
vhidden:
'!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]',
nav: 'flex items-center gap-1',
nav: 'flex items-center gap-0.5',
nav_button: cn(
buttonVariants({ variant: 'ghost' }),
'h-6 w-6 p-0 text-text-muted hover:text-text'
'h-5 w-5 p-0 text-text-muted hover:text-text',
),
nav_button_previous: '',
nav_button_next: '',
table: 'w-full border-collapse',
head_row: 'flex',
head_cell:
'text-text-muted rounded-md w-7 font-normal text-[0.7rem]',
row: 'flex w-full mt-1',
'text-text-muted rounded w-6 font-normal text-[9px] uppercase tracking-wide',
row: 'flex w-full mt-0.5',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20',
'[&:has([aria-selected])]:bg-surface-2 [&:has([aria-selected].day-outside)]:bg-surface-2/50'
'relative p-0 text-center text-xs focus-within:relative focus-within:z-20',
'[&:has([aria-selected])]:bg-surface-2 [&:has([aria-selected].day-outside)]:bg-surface-2/50',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-7 w-7 p-0 font-normal aria-selected:opacity-100'
'h-6 w-6 p-0 text-xs font-normal aria-selected:opacity-100',
),
day_range_end: 'day-range-end',
day_selected:

View File

@@ -25,6 +25,17 @@ interface PhotoStore {
* on the photo they came from instead of whatever neighbour they
* arrow-navigated to inside the preview. */
previewOriginPhotoId: string | null
/** yyyy-mm-dd ISO date of the photo currently under the top of the
* timeline viewport. Timeline/MemoriesView publish this as the user
* scrolls; the DateRangePicker in the left sidebar reads it to keep
* its displayed month in sync with what the user's actually looking
* at, so picking a range around the current position is one click. */
visibleAnchorDate: string | null
/** One-shot jump request: when a photo id is written here, the
* Timeline scrolls it into the viewport's centre and then clears
* the slot. Used by the sidebar calendar to jump to photos taken
* on a clicked date. */
jumpTargetId: string | null
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string) => void
@@ -55,6 +66,11 @@ interface PhotoStore {
* to the next surviving neighbour so the grid still has a target
* for arrow keys after the removal. */
removePhotosFromTimeline: (ids: string[]) => void
setVisibleAnchorDate: (iso: string | null) => void
/** Focus `id` and request the Timeline to scroll to it. Selection +
* anchor move with it so arrow-nav continues from there. */
jumpToPhoto: (id: string) => void
clearJumpTarget: () => void
}
export const usePhotoStore = create<PhotoStore>((set) => ({
@@ -65,9 +81,26 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
viewMode: 'grid',
visiblePhotoIds: [],
previewOriginPhotoId: null,
visibleAnchorDate: null,
jumpTargetId: null,
setPhotos: (photos) => set({ photos }),
// Guarded set — only write when the value actually changes, so
// Timeline's scroll listener can fire freely without re-rendering
// every subscriber on every pixel.
setVisibleAnchorDate: (iso) =>
set((s) => (s.visibleAnchorDate === iso ? s : { visibleAnchorDate: iso })),
jumpToPhoto: (id) =>
set({
activePhotoId: id,
rangeStartId: id,
selectedPhotos: [id],
jumpTargetId: id,
}),
clearJumpTarget: () => set({ jumpTargetId: null }),
selectPhoto: (id) => set({
selectedPhotos: [id],
activePhotoId: id,