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:
@@ -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,
|
||||
|
||||
489
frontend/src/components/filter/DateRangePicker.tsx
Normal file
489
frontend/src/components/filter/DateRangePicker.tsx
Normal 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}`
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ export function RightSidebar() {
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user