diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a212cb4..d166acc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/components/filter/DateRangePicker.tsx b/frontend/src/components/filter/DateRangePicker.tsx new file mode 100644 index 0000000..90c32b3 --- /dev/null +++ b/frontend/src/components/filter/DateRangePicker.tsx @@ -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() + 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() + 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() + 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(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 ( +
+
+ + {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 + * 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. */} +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const k = monthKeys[vItem.index] + const [y, m] = k.split('-').map(Number) + return ( +
+ +
+ ) + })} +
+
+
+ ) +} + +/** 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 + 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 ( + 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 ( + + + + Filter + + +
+

+ Date range +

+ {active && ( + + )} +
+ + +
+
+ ) +} + +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}` +} diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 0e615de..ff54b1e 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -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. */}
- {/* Date */} - { - setDateFrom(null) - setDateTo(null) - }} - > - - + {/* Date filter is rendered inline at the top of the right + * sidebar (always visible) — no pill needed here. */} {/* Type */} 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() - 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 ( -
-
{label}
- 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" - /> -
- ) -} - -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}` -} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 8f6a6d8..10bd7fc 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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() {
+ {/* 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. */} + + {/* 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(() => { + 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 ( +
+ + {open && ( +
+ +
+ )} +
+ ) +} + diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 11097a5..9f1eee6 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -213,6 +213,7 @@ export function RightSidebar() { ) + if (selectedPhotos.length === 0) { return (
{ + 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([]) 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([]) + // 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 diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx index 965fa8b..3a3a820 100644 --- a/frontend/src/components/ui/calendar.tsx +++ b/frontend/src/components/ui/calendar.tsx @@ -16,48 +16,41 @@ function Calendar({ return ( layered over a visible caption_label - // span. We position the