Compare commits
5 Commits
744a7fa0c3
...
c5582ffc65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5582ffc65 | ||
|
|
eac005109c | ||
|
|
45f1649979 | ||
|
|
66b3bc5e1f | ||
|
|
d72a218b46 |
@@ -422,6 +422,8 @@ async def get_memories(
|
|||||||
Photo.width,
|
Photo.width,
|
||||||
Photo.height,
|
Photo.height,
|
||||||
Photo.rating,
|
Photo.rating,
|
||||||
|
Photo.color_label,
|
||||||
|
Photo.is_discarded,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Photo.user_id == current_user.id,
|
Photo.user_id == current_user.id,
|
||||||
@@ -454,6 +456,8 @@ async def get_memories(
|
|||||||
"width": row.width,
|
"width": row.width,
|
||||||
"height": row.height,
|
"height": row.height,
|
||||||
"rating": row.rating,
|
"rating": row.rating,
|
||||||
|
"color_label": row.color_label,
|
||||||
|
"is_discarded": row.is_discarded,
|
||||||
})
|
})
|
||||||
|
|
||||||
memories = [
|
memories = [
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
|||||||
function MainApp() {
|
function MainApp() {
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||||
|
// Respect the user's manual collapse of the metadata panel. Once they
|
||||||
|
// close it explicitly (via `i` hotkey or the sidebar toggle button),
|
||||||
|
// selecting a new photo should NOT force it back open. Cleared when
|
||||||
|
// they open it manually again.
|
||||||
|
const rightCollapsedByUser = useRef(false)
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
|
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
|
||||||
const currentSection = useFilterStore((s) => s.currentSection)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
@@ -45,9 +50,24 @@ function MainApp() {
|
|||||||
}, [currentSection])
|
}, [currentSection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRightSidebarOpen(!!activePhotoId)
|
if (!activePhotoId) {
|
||||||
|
setRightSidebarOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// User explicitly collapsed the panel — don't undo that just because
|
||||||
|
// they picked a different photo.
|
||||||
|
if (rightCollapsedByUser.current) return
|
||||||
|
setRightSidebarOpen(true)
|
||||||
}, [activePhotoId])
|
}, [activePhotoId])
|
||||||
|
|
||||||
|
const toggleRightSidebar = () => {
|
||||||
|
setRightSidebarOpen((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
rightCollapsedByUser.current = !next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Bidirectional sync of filter store with URL query params.
|
// Bidirectional sync of filter store with URL query params.
|
||||||
useFilterUrlSync()
|
useFilterUrlSync()
|
||||||
|
|
||||||
@@ -56,11 +76,18 @@ function MainApp() {
|
|||||||
// of what filter key it's stored under.
|
// of what filter key it's stored under.
|
||||||
const { data: allPhotos } = usePhotosQuery()
|
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({
|
useKeyboardShortcuts({
|
||||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
||||||
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
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,
|
// Settings page is a full-page section — hide filter bar, right sidebar,
|
||||||
@@ -96,7 +123,7 @@ function MainApp() {
|
|||||||
leftSidebarOpen={leftSidebarOpen}
|
leftSidebarOpen={leftSidebarOpen}
|
||||||
rightSidebarOpen={showRightSidebar}
|
rightSidebarOpen={showRightSidebar}
|
||||||
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
||||||
onToggleRightSidebar={() => setRightSidebarOpen(!rightSidebarOpen)}
|
onToggleRightSidebar={toggleRightSidebar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isSettings && <DiscardActionBar />}
|
{!isSettings && <DiscardActionBar />}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
import { memo, useMemo, useRef, useState, useEffect, useCallback } from 'react'
|
||||||
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatApiError } from '../../lib/apiError'
|
import { formatApiError } from '../../lib/apiError'
|
||||||
@@ -79,26 +80,50 @@ export function DuplicatesView() {
|
|||||||
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
||||||
[groups]
|
[groups]
|
||||||
)
|
)
|
||||||
|
// Ref mirror so the stable keyboard handler can reach into the latest
|
||||||
|
// groups without re-binding the listener on every render.
|
||||||
|
const groupsRef = useRef(groups)
|
||||||
|
groupsRef.current = groups
|
||||||
|
|
||||||
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||||
// skip a row instead of jumping a single cell. The grid uses
|
// skip a row instead of jumping a single cell. The grid uses
|
||||||
// `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180).
|
// `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160).
|
||||||
// We measure the FIRST section's grid container — every section uses
|
// We measure the FIRST section's grid container — every section uses
|
||||||
// the same auto-fill rule so any one is representative.
|
// the same auto-fill rule so any one is representative.
|
||||||
const [columns, setColumns] = useState(4)
|
const [columns, setColumns] = useState(4)
|
||||||
|
// With virtualisation the first section can unmount on scroll and
|
||||||
|
// re-mount on scroll back — track the observer so we can disconnect
|
||||||
|
// cleanly every time the ref detaches (previously this leaked).
|
||||||
|
const sampleObserverRef = useRef<ResizeObserver | null>(null)
|
||||||
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
sampleObserverRef.current?.disconnect()
|
||||||
|
sampleObserverRef.current = null
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
const cols = Math.max(1, Math.floor(el.clientWidth / 160))
|
||||||
setColumns(cols)
|
setColumns(cols)
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
// Caller doesn't get the cleanup hook but ResizeObserver disconnects
|
sampleObserverRef.current = ro
|
||||||
// when the element unmounts, which is fine for our lifecycle.
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Virtualise the group list. Each group has a variable height
|
||||||
|
// (header + dynamic row count), so we let TanStack measure rendered
|
||||||
|
// DOM via `measureElement` and use a conservative estimate for
|
||||||
|
// unmeasured rows. ~360px handles a 2-row, 180px-cell section + header
|
||||||
|
// + group padding without underestimating in most cases.
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: groups.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 360,
|
||||||
|
overscan: 2,
|
||||||
|
measureElement: (el) => el.getBoundingClientRect().height,
|
||||||
|
getItemKey: (idx) => groups[idx].group_id,
|
||||||
|
})
|
||||||
|
|
||||||
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||||
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||||
// grid so we approximate ↑/↓ via the measured `columns` count and
|
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||||
@@ -135,18 +160,25 @@ export function DuplicatesView() {
|
|||||||
const nextId = allMemberIds[nextIdx]
|
const nextId = allMemberIds[nextIdx]
|
||||||
if (!nextId) return
|
if (!nextId) return
|
||||||
selectPhoto(nextId)
|
selectPhoto(nextId)
|
||||||
// Scroll the now-active cell into view if it's off-screen. The
|
// With the group list virtualised, the destination cell might not
|
||||||
// PhotoThumbnail wrapper carries data-dup-id so we can find it
|
// be mounted yet. Scroll its owning group into view first (cheap
|
||||||
// without threading refs through every cell.
|
// virtualizer op), then scrollIntoView on the cell once it mounts.
|
||||||
const el = document.querySelector<HTMLElement>(
|
const currentGroups = groupsRef.current
|
||||||
`[data-dup-id="${nextId}"]`
|
const ownerIdx = currentGroups.findIndex((g) =>
|
||||||
|
g.members.some((m) => m.id === nextId)
|
||||||
)
|
)
|
||||||
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
if (ownerIdx >= 0) virtualizer.scrollToIndex(ownerIdx, { align: 'auto' })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.querySelector<HTMLElement>(
|
||||||
|
`[data-dup-id="${nextId}"]`
|
||||||
|
)
|
||||||
|
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [allMemberIds, activePhotoId, columns, selectPhoto])
|
}, [allMemberIds, activePhotoId, columns, selectPhoto, virtualizer])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -179,8 +211,27 @@ export function DuplicatesView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable handlers — passed through memoised section + thumbnail so
|
||||||
|
// React.memo is actually effective. The `discardMutation.mutate` and
|
||||||
|
// store actions have stable identity already; we wrap them once so the
|
||||||
|
// closure identity doesn't change per render.
|
||||||
|
const allMemberIdsRef = useRef(allMemberIds)
|
||||||
|
allMemberIdsRef.current = allMemberIds
|
||||||
|
const handleKeepBest = useCallback(
|
||||||
|
(discardIds: string[]) => discardMutation.mutate(discardIds),
|
||||||
|
[discardMutation]
|
||||||
|
)
|
||||||
|
const handlePreviewMember = useCallback(
|
||||||
|
(memberId: string) => openPreview(memberId, allMemberIdsRef.current),
|
||||||
|
[openPreview]
|
||||||
|
)
|
||||||
|
const handleSelectMember = useCallback(
|
||||||
|
(memberId: string) => selectPhoto(memberId),
|
||||||
|
[selectPhoto]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-bg p-4 pb-20">
|
<div ref={scrollRef} className="h-full overflow-auto bg-bg p-4 pb-20">
|
||||||
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
<span>
|
<span>
|
||||||
@@ -191,22 +242,43 @@ export function DuplicatesView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div
|
||||||
{groups.map((group, idx) => (
|
style={{
|
||||||
<DuplicateGroupSection
|
height: virtualizer.getTotalSize(),
|
||||||
key={group.group_id}
|
position: 'relative',
|
||||||
group={group}
|
width: '100%',
|
||||||
onKeepBest={(discardIds) => discardMutation.mutate(discardIds)}
|
}}
|
||||||
onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)}
|
>
|
||||||
onSelectMember={(memberId) => selectPhoto(memberId)}
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
selectedPhotos={selectedPhotos}
|
const group = groups[vItem.index]
|
||||||
isPending={discardMutation.isPending}
|
return (
|
||||||
// Hand the column-measurement ref to the first section only
|
<div
|
||||||
// — every section's grid uses the same auto-fill rule so any
|
key={group.group_id}
|
||||||
// one is representative of the rendered column count.
|
data-index={vItem.index}
|
||||||
gridRef={idx === 0 ? sampleGridRef : undefined}
|
ref={virtualizer.measureElement}
|
||||||
/>
|
style={{
|
||||||
))}
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vItem.start}px)`,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DuplicateGroupSection
|
||||||
|
group={group}
|
||||||
|
onKeepBest={handleKeepBest}
|
||||||
|
onPreviewMember={handlePreviewMember}
|
||||||
|
onSelectMember={handleSelectMember}
|
||||||
|
selectedPhotos={selectedPhotos}
|
||||||
|
isPending={discardMutation.isPending}
|
||||||
|
// First section feeds the column-count sample ref —
|
||||||
|
// every section uses the same auto-fill rule.
|
||||||
|
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -225,7 +297,7 @@ interface DuplicateGroupSectionProps {
|
|||||||
gridRef?: (el: HTMLDivElement | null) => void
|
gridRef?: (el: HTMLDivElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function DuplicateGroupSection({
|
const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||||
group,
|
group,
|
||||||
onKeepBest,
|
onKeepBest,
|
||||||
onPreviewMember,
|
onPreviewMember,
|
||||||
@@ -251,6 +323,25 @@ function DuplicateGroupSection({
|
|||||||
const discardCount = group.member_count - 1
|
const discardCount = group.member_count - 1
|
||||||
const isExact = group.reason === 'exact'
|
const isExact = group.reason === 'exact'
|
||||||
|
|
||||||
|
// Adapt members to Photo once per group (group.members reference is
|
||||||
|
// stable across renders as long as the query data doesn't change),
|
||||||
|
// so PhotoThumbnail's memo doesn't re-render on parent tick.
|
||||||
|
const photoByMemberId = useMemo(() => {
|
||||||
|
const map = new Map<string, Photo>()
|
||||||
|
for (const m of group.members) map.set(m.id, memberToPhoto(m))
|
||||||
|
return map
|
||||||
|
}, [group.members])
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(p: Photo) => onSelectMember(p.id),
|
||||||
|
[onSelectMember]
|
||||||
|
)
|
||||||
|
const handleDoubleClick = useCallback(
|
||||||
|
(p: Photo) => onPreviewMember(p.id),
|
||||||
|
[onPreviewMember]
|
||||||
|
)
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedPhotos), [selectedPhotos])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-border bg-surface">
|
<section className="rounded-lg border border-border bg-surface">
|
||||||
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||||
@@ -295,7 +386,7 @@ function DuplicateGroupSection({
|
|||||||
className="grid gap-1 p-2"
|
className="grid gap-1 p-2"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns:
|
||||||
'repeat(auto-fill, minmax(180px, 1fr))',
|
'repeat(auto-fill, 160px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.members.map((member) => {
|
{group.members.map((member) => {
|
||||||
@@ -307,12 +398,12 @@ function DuplicateGroupSection({
|
|||||||
className="group/dup relative"
|
className="group/dup relative"
|
||||||
>
|
>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
photo={memberToPhoto(member)}
|
photo={photoByMemberId.get(member.id)!}
|
||||||
size={180}
|
size={160}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(member.id)}
|
isSelected={selectedSet.has(member.id)}
|
||||||
onClick={(p) => onSelectMember(p.id)}
|
onClick={handleClick}
|
||||||
onDoubleClick={(p) => onPreviewMember(p.id)}
|
onDoubleClick={handleDoubleClick}
|
||||||
/>
|
/>
|
||||||
{/* BEST pill — top-right, pick-coloured. Composes the same
|
{/* BEST pill — top-right, pick-coloured. Composes the same
|
||||||
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
||||||
@@ -373,7 +464,7 @@ function DuplicateGroupSection({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
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 {
|
import {
|
||||||
Star,
|
Star,
|
||||||
X,
|
X,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
|
||||||
import { FilterPill } from './FilterPill'
|
import { FilterPill } from './FilterPill'
|
||||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { MultiSelect } from '@/components/ui/multi-select'
|
import { MultiSelect } from '@/components/ui/multi-select'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
@@ -70,8 +68,6 @@ export function FilterBar({
|
|||||||
onToggleRightSidebar,
|
onToggleRightSidebar,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const filterState = useFilterStore()
|
const filterState = useFilterStore()
|
||||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
|
||||||
const dateTo = useFilterStore((s) => s.dateTo)
|
|
||||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||||
@@ -90,8 +86,6 @@ export function FilterBar({
|
|||||||
// them further (ratingMin >= 3, restrict to specific tag ids).
|
// them further (ratingMin >= 3, restrict to specific tag ids).
|
||||||
const hideFlagPill = currentSection === 'discarded'
|
const hideFlagPill = currentSection === 'discarded'
|
||||||
|
|
||||||
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
|
||||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
|
||||||
const toggleMediaType = useFilterStore((s) => s.toggleMediaType)
|
const toggleMediaType = useFilterStore((s) => s.toggleMediaType)
|
||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
||||||
@@ -124,11 +118,6 @@ export function FilterBar({
|
|||||||
}, [searchQuery, storeQ, setStoreQ])
|
}, [searchQuery, storeQ, setStoreQ])
|
||||||
|
|
||||||
// Pre-compute pill values + active flags so the JSX stays terse.
|
// 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 typeActive = mediaTypes.length > 0
|
||||||
const typeValue = typeActive
|
const typeValue = typeActive
|
||||||
? mediaTypes.map((t) => t.toUpperCase()).join(', ')
|
? mediaTypes.map((t) => t.toUpperCase()).join(', ')
|
||||||
@@ -184,23 +173,8 @@ export function FilterBar({
|
|||||||
|
|
||||||
{/* Pills — left side, scroll horizontally if they overflow. */}
|
{/* Pills — left side, scroll horizontally if they overflow. */}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||||
{/* Date */}
|
{/* Date filter is rendered inline at the top of the right
|
||||||
<FilterPill
|
* sidebar (always visible) — no pill needed here. */}
|
||||||
label="Date"
|
|
||||||
value={dateValue}
|
|
||||||
isActive={dateActive}
|
|
||||||
onClear={() => {
|
|
||||||
setDateFrom(null)
|
|
||||||
setDateTo(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DateRangePicker
|
|
||||||
from={dateFrom}
|
|
||||||
to={dateTo}
|
|
||||||
onFromChange={setDateFrom}
|
|
||||||
onToChange={setDateTo}
|
|
||||||
/>
|
|
||||||
</FilterPill>
|
|
||||||
|
|
||||||
{/* Type */}
|
{/* Type */}
|
||||||
<FilterPill
|
<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}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,13 +44,27 @@ import {
|
|||||||
* - filter heapId: which heap is currently filtered to (visual)
|
* - filter heapId: which heap is currently filtered to (visual)
|
||||||
* - heap.is_active: which heap T adds to (server-side, single per row)
|
* - heap.is_active: which heap T adds to (server-side, single per row)
|
||||||
*/
|
*/
|
||||||
export function HeapsPanel() {
|
interface HeapsPanelProps {
|
||||||
|
/** Controlled expand state. When both props are supplied the panel
|
||||||
|
* defers to the parent for collapse/expand, so the sidebar's pane
|
||||||
|
* sizing layer can flex the Heaps pane only when it is open. */
|
||||||
|
expanded?: boolean
|
||||||
|
onExpandedChange?: (expanded: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeapsPanel({ expanded: expandedProp, onExpandedChange }: HeapsPanelProps = {}) {
|
||||||
const { data: heaps = [] } = useHeapsQuery()
|
const { data: heaps = [] } = useHeapsQuery()
|
||||||
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||||
const currentSection = useFilterStore((s) => s.currentSection)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expandedInternal, setExpandedInternal] = useState(true)
|
||||||
|
const expanded = expandedProp ?? expandedInternal
|
||||||
|
const setExpanded = (next: boolean | ((prev: boolean) => boolean)) => {
|
||||||
|
const resolved = typeof next === 'function' ? (next as (p: boolean) => boolean)(expanded) : next
|
||||||
|
if (onExpandedChange) onExpandedChange(resolved)
|
||||||
|
else setExpandedInternal(resolved)
|
||||||
|
}
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
// Which heap row is currently being hovered with a drag — used to render
|
// Which heap row is currently being hovered with a drag — used to render
|
||||||
@@ -174,13 +188,23 @@ export function HeapsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Section header — eyebrow style to match the LeftSidebar
|
{/* Section header — mirrors the Folders section eyebrow in
|
||||||
* library/folders headers. */}
|
* LeftSidebar so Heaps sits alongside it at the same visual
|
||||||
|
* tier, with heap rows indented the same as folder rows. */}
|
||||||
<div
|
<div
|
||||||
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<button className="rounded p-0.5 hover:bg-surface-offset">
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-surface-offset"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setExpanded((v) => !v)
|
||||||
|
}}
|
||||||
|
aria-label="Toggle heaps"
|
||||||
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
@@ -209,6 +233,7 @@ export function HeapsPanel() {
|
|||||||
className="flex items-center gap-1 px-2 py-0.5"
|
className="flex items-center gap-1 px-2 py-0.5"
|
||||||
style={{ paddingLeft: '20px' }}
|
style={{ paddingLeft: '20px' }}
|
||||||
>
|
>
|
||||||
|
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
@@ -237,10 +262,11 @@ export function HeapsPanel() {
|
|||||||
|
|
||||||
{heaps.length === 0 && !creating && (
|
{heaps.length === 0 && !creating && (
|
||||||
<div
|
<div
|
||||||
className="px-2 py-0.5 text-[11px] text-text-faint"
|
className="flex items-center gap-1 px-2 py-0.5 text-[11px] text-text-faint"
|
||||||
style={{ paddingLeft: '20px' }}
|
style={{ paddingLeft: '20px' }}
|
||||||
>
|
>
|
||||||
No heaps yet
|
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>No heaps yet</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -308,6 +334,10 @@ export function HeapsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Chevron-slot spacer — heap rows don't expand, but this
|
||||||
|
* reserves the same width that leaf folder rows use so
|
||||||
|
* icons and labels line up across the two hierarchies. */}
|
||||||
|
<div className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||||
<ShoppingBasket
|
<ShoppingBasket
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3.5 w-3.5 flex-shrink-0',
|
'h-3.5 w-3.5 flex-shrink-0',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Upload as UploadIcon,
|
Upload as UploadIcon,
|
||||||
Download as DownloadIcon,
|
Download as DownloadIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { DateRangePicker } from '../filter/DateRangePicker'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
|
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -81,6 +82,9 @@ export function LeftSidebar() {
|
|||||||
const { user, isAdmin, logout } = useAuth()
|
const { user, isAdmin, logout } = useAuth()
|
||||||
const scanActivity = useScanActivity()
|
const scanActivity = useScanActivity()
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
|
// Library pane collapse. Its body holds Views, Folders, Shared, and
|
||||||
|
// Heaps, so a single toggle hides the whole navigation area.
|
||||||
|
const [libraryPaneOpen, setLibraryPaneOpen] = useState(true)
|
||||||
// Inline rename state for source-root rows. Stores the id being edited
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
// and the draft name. Double-click a folder row to start.
|
// and the draft name. Double-click a folder row to start.
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
@@ -834,15 +838,40 @@ export function LeftSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
{/* Header with collapse button. Matches the right sidebar header
|
{/* Active heap card — pinned at the very top so it stays visible
|
||||||
* so both panels have symmetric affordances. */}
|
* even when the date filter is expanded into a tall calendar.
|
||||||
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
|
* Returns null when no heap is active, so the layout collapses
|
||||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
* cleanly. */}
|
||||||
Library
|
<ActiveHeapCard />
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Date range filter — always visible, collapsible. Drives the
|
||||||
|
* global dateFrom/dateTo on the filter store, so it applies to
|
||||||
|
* every section regardless of which tree item is selected. */}
|
||||||
|
<DateFilterSection />
|
||||||
|
|
||||||
|
{/* Resizable two-pane region: Library (Views + Folders + Shared)
|
||||||
|
* on top, Heaps on the bottom. Each pane collapses to its
|
||||||
|
* header; when both are expanded a drag divider splits the
|
||||||
|
* vertical space between them (persisted to localStorage). */}
|
||||||
|
{/* Library pane — the sole navigation section. Contains Views,
|
||||||
|
* Folders, Shared-with-me, and Heaps in a single scroll area;
|
||||||
|
* collapses to its header when hidden. */}
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{ flex: libraryPaneOpen ? '1 1 0' : '0 0 auto' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="group flex h-9 flex-shrink-0 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
||||||
|
onClick={() => setLibraryPaneOpen((v) => !v)}
|
||||||
|
aria-expanded={libraryPaneOpen}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">Library</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setUploadTarget({ open: true, folderId: null })}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setUploadTarget({ open: true, folderId: null })
|
||||||
|
}}
|
||||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
title="Upload photos"
|
title="Upload photos"
|
||||||
aria-label="Upload photos"
|
aria-label="Upload photos"
|
||||||
@@ -850,63 +879,61 @@ export function LeftSidebar() {
|
|||||||
<UploadIcon className="h-3.5 w-3.5" />
|
<UploadIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{libraryPaneOpen && (
|
||||||
{/* Active heap card — pinned just below the Library header so
|
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||||
* toasts (bottom-left fixed) can't cover it. Returns null when
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
* no heap is active, so the layout collapses cleanly. */}
|
|
||||||
<ActiveHeapCard />
|
|
||||||
|
|
||||||
{/* Tree View */}
|
{/* Shared with me — folders shared by other users */}
|
||||||
<div className="flex-1 overflow-y-auto pb-2">
|
{sharedFolders.length > 0 && (
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
<div className="mt-1">
|
||||||
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||||
{/* Shared with me — folders shared by other users */}
|
Shared with me
|
||||||
{sharedFolders.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
|
||||||
Shared with me
|
|
||||||
</div>
|
|
||||||
{sharedFolders.map((sf) => {
|
|
||||||
const isSelected = currentSection === `folder-${sf.id}`
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sf.id}
|
|
||||||
className={cn(
|
|
||||||
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
|
||||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: '20px' }}
|
|
||||||
onClick={() =>
|
|
||||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Users
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 flex-shrink-0',
|
|
||||||
isSelected ? 'text-primary' : 'text-text-muted'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate" title={sf.name}>
|
|
||||||
{sf.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
|
||||||
{sf.owner_username}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
|
||||||
{sf.permission}
|
|
||||||
</span>
|
|
||||||
{sf.photo_count > 0 && (
|
|
||||||
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
|
||||||
{sf.photo_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
{sharedFolders.map((sf) => {
|
||||||
})}
|
const isSelected = currentSection === `folder-${sf.id}`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sf.id}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||||
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: '20px' }}
|
||||||
|
onClick={() =>
|
||||||
|
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Users
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 flex-shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-text-muted'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate" title={sf.name}>
|
||||||
|
{sf.name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-0.5 truncate text-[10px] text-text-faint">
|
||||||
|
{sf.owner_username}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
|
||||||
|
{sf.permission}
|
||||||
|
</span>
|
||||||
|
{sf.photo_count > 0 && (
|
||||||
|
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||||
|
{sf.photo_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Heaps — nested inside the Library section; HeapsPanel owns
|
||||||
|
* its own eyebrow header + collapse state. */}
|
||||||
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HeapsPanel />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
||||||
@@ -972,3 +999,70 @@ 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">
|
||||||
|
<div
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="group flex h-9 cursor-pointer items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
||||||
|
aria-expanded={open}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{active ? summary : 'Date'}
|
||||||
|
</span>
|
||||||
|
{active && (
|
||||||
|
<span className="rounded bg-primary/20 px-1 py-px text-[9px] uppercase tracking-wider text-primary">
|
||||||
|
Filtering
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="border-b border-border px-3 py-3">
|
||||||
|
<DateRangePicker
|
||||||
|
from={dateFrom}
|
||||||
|
to={dateTo}
|
||||||
|
onFromChange={setDateFrom}
|
||||||
|
onToChange={setDateTo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@@ -10,10 +10,15 @@ import type { Photo } from '../../types/photo'
|
|||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||||
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
|
|
||||||
const CELL_SIZE = 180
|
// Match Timeline's THUMBNAIL_SIZE / GAP so memories cells line up
|
||||||
|
// visually with what the user sees in the main grid.
|
||||||
|
const THUMBNAIL_SIZE = 160
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "On this day" view. Same visual + interaction grid as Timeline —
|
* "On this day" view. Same visual + interaction grid as Timeline —
|
||||||
* PhotoThumbnail cells wired up to the shared photo store, so selection,
|
* PhotoThumbnail cells wired up to the shared photo store, so selection,
|
||||||
@@ -46,7 +51,9 @@ export function MemoriesView() {
|
|||||||
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
|
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
|
||||||
const selectRange = usePhotoStore((s) => s.selectRange)
|
const selectRange = usePhotoStore((s) => s.selectRange)
|
||||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
const searchQuery = useFilterStore((s) => s.q)
|
const searchQuery = useFilterStore((s) => s.q)
|
||||||
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
const visibleSequenceRef = useRef<string[]>([])
|
const visibleSequenceRef = useRef<string[]>([])
|
||||||
visibleSequenceRef.current = visibleSequence
|
visibleSequenceRef.current = visibleSequence
|
||||||
@@ -66,6 +73,87 @@ export function MemoriesView() {
|
|||||||
[openPreview],
|
[openPreview],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Measure the scroll container directly and subtract its padding to
|
||||||
|
// get the real grid width. A ResizeObserver on the container itself
|
||||||
|
// always fires on width changes — sentinel elements can miss changes
|
||||||
|
// in some edge cases (zero-height, detached during loading states).
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
const roRef = useRef<ResizeObserver | null>(null)
|
||||||
|
const scrollRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
roRef.current?.disconnect()
|
||||||
|
roRef.current = null
|
||||||
|
scrollElRef.current = el
|
||||||
|
if (!el) return
|
||||||
|
const measure = () => {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const padX =
|
||||||
|
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||||
|
setContainerWidth(Math.max(0, el.clientWidth - padX))
|
||||||
|
}
|
||||||
|
measure()
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
roRef.current = ro
|
||||||
|
}, [])
|
||||||
|
const scrollElRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||||
|
// Fixed-size cells — always exactly THUMBNAIL_SIZE px, regardless of
|
||||||
|
// container width. See Timeline's column-math comment for the
|
||||||
|
// tradeoff (may leave a small gap on the right edge).
|
||||||
|
const { columns, cellSize } = useMemo(() => {
|
||||||
|
if (containerWidth === 0) return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||||
|
const cols = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)),
|
||||||
|
)
|
||||||
|
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
||||||
|
}, [containerWidth])
|
||||||
|
|
||||||
|
// Break each year's photos into rows of `columns` ids — matches the
|
||||||
|
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
||||||
|
// naturally because rows flow in visual order across groups.
|
||||||
|
const gridNavRows = useMemo(() => {
|
||||||
|
const rows: { cells: { id: string }[] }[] = []
|
||||||
|
for (const group of memories) {
|
||||||
|
const cells = group.photos.map((m) => ({ id: m.id }))
|
||||||
|
for (let i = 0; i < cells.length; i += columns) {
|
||||||
|
rows.push({ cells: cells.slice(i, i + columns) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}, [memories, columns])
|
||||||
|
|
||||||
|
const scrollRowIntoView = useCallback((rowIdx: number) => {
|
||||||
|
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
|
||||||
|
if (!firstId) return
|
||||||
|
const scrollEl = scrollElRef.current
|
||||||
|
if (!scrollEl) return
|
||||||
|
const cellEl = scrollEl.querySelector<HTMLElement>(
|
||||||
|
`[data-photo-id="${firstId}"]`,
|
||||||
|
)
|
||||||
|
if (!cellEl) return
|
||||||
|
const peek = Math.round(cellSize * 0.35)
|
||||||
|
const cellTop = cellEl.offsetTop
|
||||||
|
const cellBottom = cellTop + cellEl.offsetHeight
|
||||||
|
const viewTop = scrollEl.scrollTop
|
||||||
|
const viewBottom = viewTop + scrollEl.clientHeight
|
||||||
|
if (cellTop - peek < viewTop) {
|
||||||
|
scrollEl.scrollTo({ top: Math.max(0, cellTop - peek) })
|
||||||
|
} else if (cellBottom + peek > viewBottom) {
|
||||||
|
scrollEl.scrollTo({ top: cellBottom + peek - scrollEl.clientHeight })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
// Rows change on resize / data load — read via ref so the callback
|
||||||
|
// identity stays stable.
|
||||||
|
const gridNavRowsRef = useRef(gridNavRows)
|
||||||
|
gridNavRowsRef.current = gridNavRows
|
||||||
|
|
||||||
|
useGridKeyNav({
|
||||||
|
rows: gridNavRows,
|
||||||
|
enabled: viewMode === 'grid',
|
||||||
|
scrollRowIntoView,
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-text-muted">
|
<div className="flex h-full items-center justify-center text-text-muted">
|
||||||
@@ -90,6 +178,7 @@ export function MemoriesView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
className="h-full overflow-auto bg-bg p-4"
|
className="h-full overflow-auto bg-bg p-4"
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label="Memories"
|
aria-label="Memories"
|
||||||
@@ -112,22 +201,24 @@ export function MemoriesView() {
|
|||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
|
||||||
gridAutoRows: `${CELL_SIZE}px`,
|
gridAutoRows: `${cellSize}px`,
|
||||||
gap: `${GAP}px`,
|
gap: `${GAP}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.photos.map((m) => (
|
{group.photos.map((m) => (
|
||||||
<PhotoThumbnail
|
<div key={m.id} data-photo-id={m.id}>
|
||||||
key={m.id}
|
<PhotoThumbnail
|
||||||
photo={memoryToPhoto(m)}
|
photo={memoryToPhoto(m)}
|
||||||
size={CELL_SIZE}
|
size={cellSize}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(m.id)}
|
isSelected={selectedPhotos.includes(m.id)}
|
||||||
searchQuery={searchQuery}
|
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||||
onClick={handleCellClick}
|
searchQuery={searchQuery}
|
||||||
onDoubleClick={handleCellDoubleClick}
|
onClick={handleCellClick}
|
||||||
/>
|
onDoubleClick={handleCellDoubleClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -151,7 +242,8 @@ function memoryToPhoto(m: MemoryPhoto): Photo {
|
|||||||
height: m.height,
|
height: m.height,
|
||||||
taken_at: m.taken_at,
|
taken_at: m.taken_at,
|
||||||
rating: m.rating,
|
rating: m.rating,
|
||||||
is_discarded: false,
|
color_label: m.color_label,
|
||||||
|
is_discarded: m.is_discarded,
|
||||||
is_duplicate: false,
|
is_duplicate: false,
|
||||||
file_hash: '',
|
file_hash: '',
|
||||||
folder_id: null,
|
folder_id: null,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
@@ -11,48 +12,87 @@ interface PreviewFilmstripProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CELL_SIZE = 72
|
const CELL_SIZE = 72
|
||||||
|
const GAP = 4
|
||||||
|
|
||||||
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
/**
|
||||||
const activeRef = useRef<HTMLButtonElement>(null)
|
* Horizontal virtualised filmstrip. Mounting thousands of `<button>`
|
||||||
|
* nodes on preview open used to dominate the perceived open latency
|
||||||
|
* for large libraries — with virtualisation only a viewport's worth
|
||||||
|
* (~15-20 cells) ever hits the DOM.
|
||||||
|
*/
|
||||||
|
export function PreviewFilmstrip({
|
||||||
|
photos,
|
||||||
|
currentIndex,
|
||||||
|
onSelect,
|
||||||
|
}: PreviewFilmstripProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
useEffect(() => {
|
const virtualizer = useVirtualizer({
|
||||||
activeRef.current?.scrollIntoView({
|
horizontal: true,
|
||||||
block: 'nearest',
|
count: photos.length,
|
||||||
inline: 'center',
|
getScrollElement: () => scrollRef.current,
|
||||||
behavior: 'smooth',
|
estimateSize: () => CELL_SIZE + GAP,
|
||||||
})
|
overscan: 10,
|
||||||
}, [currentIndex])
|
getItemKey: (idx) => photos[idx].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Centre the active cell when the index changes. Using layout-effect
|
||||||
|
// so the scroll happens before paint — avoids a visible jump.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (currentIndex < 0 || currentIndex >= photos.length) return
|
||||||
|
virtualizer.scrollToIndex(currentIndex, { align: 'center' })
|
||||||
|
}, [currentIndex, photos.length, virtualizer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
|
<div
|
||||||
{photos.map((photo, index) => {
|
ref={scrollRef}
|
||||||
const isActive = index === currentIndex
|
className="relative h-24 shrink-0 overflow-x-auto overflow-y-hidden border-t border-border bg-surface py-2"
|
||||||
const isInActiveHeap = activeHeapMembers.has(photo.id)
|
>
|
||||||
return (
|
<div
|
||||||
<button
|
style={{
|
||||||
key={photo.id}
|
width: virtualizer.getTotalSize(),
|
||||||
ref={isActive ? activeRef : null}
|
height: '100%',
|
||||||
onClick={() => onSelect(photo.id)}
|
position: 'relative',
|
||||||
className={cn(
|
paddingLeft: 8,
|
||||||
'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
paddingRight: 8,
|
||||||
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
|
}}
|
||||||
!isActive && isInActiveHeap && 'opacity-100',
|
>
|
||||||
!isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
)}
|
const photo = photos[vItem.index]
|
||||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
const isActive = vItem.index === currentIndex
|
||||||
title={photo.filename}
|
const isInActiveHeap = activeHeapMembers.has(photo.id)
|
||||||
>
|
return (
|
||||||
<FilmstripThumb photo={photo} />
|
<button
|
||||||
{isInActiveHeap && (
|
key={photo.id}
|
||||||
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
|
onClick={() => onSelect(photo.id)}
|
||||||
)}
|
className={cn(
|
||||||
{isActive && (
|
'absolute top-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
||||||
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
|
isActive &&
|
||||||
)}
|
'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
|
||||||
</button>
|
!isActive && isInActiveHeap && 'opacity-100',
|
||||||
)
|
!isActive &&
|
||||||
})}
|
!isInActiveHeap &&
|
||||||
|
'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: CELL_SIZE,
|
||||||
|
height: CELL_SIZE,
|
||||||
|
transform: `translateX(${vItem.start}px)`,
|
||||||
|
}}
|
||||||
|
title={photo.filename}
|
||||||
|
>
|
||||||
|
<FilmstripThumb photo={photo} />
|
||||||
|
{isInActiveHeap && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { format, parseISO } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff } from 'lucide-react'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers.
|
||||||
const THUMBNAIL_SIZE = 200
|
const THUMBNAIL_SIZE = 160
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
const PADDING = 16
|
const PADDING = 16
|
||||||
const HEADER_HEIGHT = 36
|
const HEADER_HEIGHT = 36
|
||||||
@@ -39,7 +40,6 @@ type TimelineItem =
|
|||||||
function buildItems(
|
function buildItems(
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
columns: number,
|
columns: number,
|
||||||
rowHeight: number,
|
|
||||||
sortBy: string,
|
sortBy: string,
|
||||||
groupBy: string,
|
groupBy: string,
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
@@ -47,7 +47,9 @@ function buildItems(
|
|||||||
|
|
||||||
const items: TimelineItem[] = []
|
const items: TimelineItem[] = []
|
||||||
|
|
||||||
// Helper: split a flat array of cells into rows of `columns` cells.
|
// Row items carry a placeholder `height: 0` — the virtualizer reads
|
||||||
|
// the live cellSize off a ref at render time (see `estimateSize`),
|
||||||
|
// so resizing the main column doesn't force items to rebuild.
|
||||||
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
|
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
|
||||||
for (let i = 0; i < cells.length; i += columns) {
|
for (let i = 0; i < cells.length; i += columns) {
|
||||||
const slice = cells.slice(i, i + columns)
|
const slice = cells.slice(i, i + columns)
|
||||||
@@ -55,7 +57,7 @@ function buildItems(
|
|||||||
type: 'row',
|
type: 'row',
|
||||||
key: `${groupKey}::row::${i}`,
|
key: `${groupKey}::row::${i}`,
|
||||||
cells: slice,
|
cells: slice,
|
||||||
height: rowHeight + GAP,
|
height: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,23 @@ function buildItems(
|
|||||||
label: currentLabel,
|
label: currentLabel,
|
||||||
height: HEADER_HEIGHT,
|
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)
|
pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
|
||||||
bucketIndex++
|
bucketIndex++
|
||||||
bucket = []
|
bucket = []
|
||||||
@@ -99,9 +118,21 @@ function buildItems(
|
|||||||
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
||||||
let label: string
|
let label: string
|
||||||
if (dateStr) {
|
if (dateStr) {
|
||||||
try {
|
// Same slice-first strategy the scroll-date chip uses — the
|
||||||
label = format(parseISO(dateStr), 'MMMM yyyy')
|
// date portion is trusted verbatim so local timezones can't
|
||||||
} catch {
|
// 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'
|
label = 'Unknown date'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -119,7 +150,7 @@ function buildItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
||||||
// position rows will render. We measure THIS instead of parentRef,
|
// position rows will render. We measure THIS instead of parentRef,
|
||||||
// because parentRef has padding and we'd otherwise have to subtract
|
// because parentRef has padding and we'd otherwise have to subtract
|
||||||
@@ -133,7 +164,6 @@ export function Timeline() {
|
|||||||
selectPhoto,
|
selectPhoto,
|
||||||
togglePhotoSelection,
|
togglePhotoSelection,
|
||||||
selectRange,
|
selectRange,
|
||||||
clearSelection,
|
|
||||||
openPreview,
|
openPreview,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
// Pulled via a focused selector so the publisher subscription doesn't
|
// Pulled via a focused selector so the publisher subscription doesn't
|
||||||
@@ -149,33 +179,20 @@ export function Timeline() {
|
|||||||
const searchQuery = useFilterStore((s) => s.q)
|
const searchQuery = useFilterStore((s) => s.q)
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
|
||||||
// Calculate number of columns + actual cell size based on container
|
// Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no
|
||||||
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
// longer stretch to absorb the remaining width — we prefer a stable
|
||||||
// fill the remaining space, so we never leave a horizontal gap on
|
// cell size across sidebar-open / sidebar-closed states over a
|
||||||
// the right side of the grid.
|
// perfectly-flush right edge. A small gap may remain on the right
|
||||||
//
|
// when containerWidth isn't an integer multiple of (T+G).
|
||||||
// Column math: with N columns there are N-1 inter-cell gaps, so the
|
|
||||||
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
|
|
||||||
// in the available width gives N = floor((available + G) / (T + G)).
|
|
||||||
// The previous formula floor((available) / (T + G)) was off-by-one
|
|
||||||
// and lost a whole column whenever the remainder almost fit.
|
|
||||||
const { columns, cellSize } = useMemo(() => {
|
const { columns, cellSize } = useMemo(() => {
|
||||||
if (containerWidth === 0) {
|
if (containerWidth === 0) {
|
||||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||||
}
|
}
|
||||||
// containerWidth here is the sentinel's actual rendered width — no
|
|
||||||
// padding subtraction needed, the sentinel already lives inside the
|
|
||||||
// padded scroll container.
|
|
||||||
const available = containerWidth
|
|
||||||
const cols = Math.max(
|
const cols = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
|
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP))
|
||||||
)
|
)
|
||||||
// Exact float — no floor. cellSize × cols + (cols-1) × gap == available
|
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
||||||
// by construction, so the row fills edge-to-edge without any
|
|
||||||
// sub-pixel rounding gap.
|
|
||||||
const cell = (available - (cols - 1) * GAP) / cols
|
|
||||||
return { columns: cols, cellSize: cell }
|
|
||||||
}, [containerWidth])
|
}, [containerWidth])
|
||||||
|
|
||||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
@@ -192,6 +209,12 @@ export function Timeline() {
|
|||||||
// explicitly clears the selection (Escape), we don't re-focus, so
|
// explicitly clears the selection (Escape), we don't re-focus, so
|
||||||
// the metadata sidebar can collapse and stay collapsed.
|
// the metadata sidebar can collapse and stay collapsed.
|
||||||
const didAutoFocusRef = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (didAutoFocusRef.current) return
|
if (didAutoFocusRef.current) return
|
||||||
if (viewMode !== 'grid') return
|
if (viewMode !== 'grid') return
|
||||||
@@ -199,9 +222,10 @@ export function Timeline() {
|
|||||||
didAutoFocusRef.current = true
|
didAutoFocusRef.current = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (photos.length === 0) return
|
const firstVisible = visibleSequenceRef.current[0]
|
||||||
|
if (!firstVisible) return
|
||||||
didAutoFocusRef.current = true
|
didAutoFocusRef.current = true
|
||||||
selectPhoto(photos[0].id)
|
selectPhoto(firstVisible)
|
||||||
}, [viewMode, activePhotoId, photos, selectPhoto])
|
}, [viewMode, activePhotoId, photos, selectPhoto])
|
||||||
|
|
||||||
// Membership in the active heap (drives the green tint on each
|
// Membership in the active heap (drives the green tint on each
|
||||||
@@ -212,9 +236,8 @@ export function Timeline() {
|
|||||||
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
|
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
|
||||||
// identity-stable callbacks let it skip re-render on unrelated store
|
// identity-stable callbacks let it skip re-render on unrelated store
|
||||||
// churn (e.g. heap membership invalidation). visibleSequence is read
|
// churn (e.g. heap membership invalidation). visibleSequence is read
|
||||||
// through a ref at click time so scrolling doesn't rebind the
|
// through the ref declared above at click time so scrolling doesn't
|
||||||
// double-click handler.
|
// rebind the double-click handler.
|
||||||
const visibleSequenceRef = useRef<string[]>([])
|
|
||||||
const handleCellClick = useCallback(
|
const handleCellClick = useCallback(
|
||||||
(photo: Photo, e: React.MouseEvent) => {
|
(photo: Photo, e: React.MouseEvent) => {
|
||||||
if (e.shiftKey) selectRange(photo.id)
|
if (e.shiftKey) selectRange(photo.id)
|
||||||
@@ -232,11 +255,28 @@ export function Timeline() {
|
|||||||
|
|
||||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||||
// photos. Date headers appear only in the main timeline (groupBy='date').
|
// photos. Date headers appear only in the main timeline (groupBy='date').
|
||||||
|
//
|
||||||
|
// Notably NOT dependent on `cellSize` — row heights are read off a ref
|
||||||
|
// at virtualizer-measurement time instead. This stops `items` (and
|
||||||
|
// every downstream memo) from rebuilding on every sub-pixel tick of
|
||||||
|
// the sidebar CSS transition, which used to cause visible jank in
|
||||||
|
// timelines with thousands of photos.
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
|
() => buildItems(photos, columns, sortBy, groupBy),
|
||||||
[photos, columns, cellSize, sortBy, groupBy]
|
[photos, columns, sortBy, groupBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live cell size, consumed by the virtualizer's estimateSize so row
|
||||||
|
// heights stay accurate as the column fluidly resizes.
|
||||||
|
const cellSizeRef = useRef(cellSize)
|
||||||
|
cellSizeRef.current = cellSize
|
||||||
|
|
||||||
|
// Row items carry a placeholder height of 0 (see buildItems). Any
|
||||||
|
// code walking `items` for scroll offsets must resolve it against the
|
||||||
|
// current cellSize; headers carry their fixed height verbatim.
|
||||||
|
const effectiveHeight = (it: TimelineItem) =>
|
||||||
|
it.type === 'header' ? it.height : cellSizeRef.current + GAP
|
||||||
|
|
||||||
// Used by the sticky header, the row-date index, and the floating
|
// Used by the sticky header, the row-date index, and the floating
|
||||||
// scrollbar chip — all three only make sense in date-sorted views.
|
// scrollbar chip — all three only make sense in date-sorted views.
|
||||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||||
@@ -246,27 +286,43 @@ export function Timeline() {
|
|||||||
const headerOffsets = useMemo(() => {
|
const headerOffsets = useMemo(() => {
|
||||||
const result: { offset: number; label: string }[] = []
|
const result: { offset: number; label: string }[] = []
|
||||||
let cumulative = 0
|
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) {
|
for (const item of items) {
|
||||||
if (item.type === 'header') {
|
if (item.type === 'header') {
|
||||||
result.push({ offset: cumulative, label: item.label })
|
result.push({ offset: cumulative, label: item.label })
|
||||||
|
cumulative += item.height
|
||||||
|
} else {
|
||||||
|
cumulative += rowH
|
||||||
}
|
}
|
||||||
cumulative += item.height
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [items])
|
}, [items, cellSize])
|
||||||
|
|
||||||
// Virtual scrolling setup with per-item heights.
|
// Virtual scrolling setup with per-item heights.
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: (index) => items[index]?.height ?? cellSize,
|
estimateSize: (index) => {
|
||||||
|
const it = items[index]
|
||||||
|
if (!it) return cellSizeRef.current + GAP
|
||||||
|
// Row items carry `height: 0` as a placeholder — always resolved
|
||||||
|
// to the current cellSize via the ref. Headers carry their own
|
||||||
|
// fixed height.
|
||||||
|
return it.type === 'header' ? it.height : cellSizeRef.current + GAP
|
||||||
|
},
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-measure when items change (column count, group structure).
|
// Re-measure when items change (column count, group structure) or
|
||||||
|
// cellSize shifts (sidebar collapse, window resize). Cheap — it just
|
||||||
|
// walks the item list and recomputes heights.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure()
|
virtualizer.measure()
|
||||||
}, [items, virtualizer])
|
}, [items, cellSize, virtualizer])
|
||||||
|
|
||||||
// Track scroll position so we can (a) show the current group label as
|
// Track scroll position so we can (a) show the current group label as
|
||||||
// a pinned overlay at the top of the scroll container and (b) drive
|
// a pinned overlay at the top of the scroll container and (b) drive
|
||||||
@@ -338,6 +394,12 @@ export function Timeline() {
|
|||||||
if (!isDateSort) return [] as { offset: number; raw: string | null }[]
|
if (!isDateSort) return [] as { offset: number; raw: string | null }[]
|
||||||
const out: { offset: number; raw: string | null }[] = []
|
const out: { offset: number; raw: string | null }[] = []
|
||||||
let cum = 0
|
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) {
|
for (const item of items) {
|
||||||
if (item.type === 'row') {
|
if (item.type === 'row') {
|
||||||
const first = item.cells[0]?.photo
|
const first = item.cells[0]?.photo
|
||||||
@@ -346,11 +408,13 @@ export function Timeline() {
|
|||||||
? first?.added_at ?? first?.taken_at ?? null
|
? first?.added_at ?? first?.taken_at ?? null
|
||||||
: first?.taken_at ?? null
|
: first?.taken_at ?? null
|
||||||
out.push({ offset: cum, raw })
|
out.push({ offset: cum, raw })
|
||||||
|
cum += rowH
|
||||||
|
} else {
|
||||||
|
cum += item.height
|
||||||
}
|
}
|
||||||
cum += item.height
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [items, sortBy, isDateSort])
|
}, [items, sortBy, isDateSort, cellSize])
|
||||||
|
|
||||||
// Date shown in the floating chip next to the scrollbar. Finds the
|
// Date shown in the floating chip next to the scrollbar. Finds the
|
||||||
// deepest row whose offset is at/above the viewport top (plus a
|
// deepest row whose offset is at/above the viewport top (plus a
|
||||||
@@ -358,11 +422,10 @@ export function Timeline() {
|
|||||||
// photo's capture date. Null when not date-sorted, not scrolling, or
|
// 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
|
// when the topmost row has no date — we'd rather hide the chip than
|
||||||
// show an unhelpful "Unknown".
|
// show an unhelpful "Unknown".
|
||||||
const scrollDateLabel = useMemo(() => {
|
const scrollRaw = useMemo(() => {
|
||||||
if (!isDateSort || rowDateIndex.length === 0) return null
|
if (!isDateSort || rowDateIndex.length === 0) return null
|
||||||
const target = scrollMetrics.top + 20
|
const target = scrollMetrics.top + 20
|
||||||
let raw: string | null = null
|
let raw: string | null = null
|
||||||
// Binary search: largest offset ≤ target.
|
|
||||||
let lo = 0
|
let lo = 0
|
||||||
let hi = rowDateIndex.length - 1
|
let hi = rowDateIndex.length - 1
|
||||||
while (lo <= hi) {
|
while (lo <= hi) {
|
||||||
@@ -374,13 +437,36 @@ export function Timeline() {
|
|||||||
hi = mid - 1
|
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 {
|
try {
|
||||||
return format(parseISO(raw), 'MMM d, yyyy')
|
return format(new Date(y, m - 1, d), 'MMM d, yyyy')
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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.
|
// Vertical position of the floating chip in scroll-viewport pixels.
|
||||||
// Tracks the scrollbar thumb's position by mapping scroll progress
|
// Tracks the scrollbar thumb's position by mapping scroll progress
|
||||||
@@ -411,41 +497,29 @@ export function Timeline() {
|
|||||||
return current
|
return current
|
||||||
}, [headerOffsets, scrollTop])
|
}, [headerOffsets, scrollTop])
|
||||||
|
|
||||||
// Measure the sentinel's actual rendered width on mount, window
|
// Measure the scroll container directly and subtract its padding to
|
||||||
// resize, and any layout change driven by the sidebar collapse /
|
// get the real grid width. Observing the scroll container itself (vs
|
||||||
// right panel toggle. ResizeObserver picks up everything window
|
// a sentinel child) reliably fires on every sidebar toggle and
|
||||||
// resize misses (sidebar collapse doesn't fire window resize).
|
// window resize — no edge cases with zero-height observers or
|
||||||
//
|
// early-returned loading states detaching the sentinel.
|
||||||
// Uses a callback ref (not useRef + useEffect) because Timeline
|
|
||||||
// early-returns a loading/empty state before the sentinel exists,
|
|
||||||
// so a mount-only effect would see a null ref and never install
|
|
||||||
// the observer. The callback ref fires whenever the sentinel
|
|
||||||
// actually attaches, which is the moment we can measure it.
|
|
||||||
const roRef = useRef<ResizeObserver | null>(null)
|
const roRef = useRef<ResizeObserver | null>(null)
|
||||||
const measureElRef = useRef<HTMLDivElement | null>(null)
|
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
|
|
||||||
roRef.current?.disconnect()
|
roRef.current?.disconnect()
|
||||||
roRef.current = null
|
roRef.current = null
|
||||||
measureElRef.current = el
|
parentRef.current = el
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => setContainerWidth(el.clientWidth)
|
const measure = () => {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const padX =
|
||||||
|
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||||
|
setContainerWidth(Math.max(0, el.clientWidth - padX))
|
||||||
|
}
|
||||||
measure()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
roRef.current = ro
|
roRef.current = ro
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||||
const onResize = () => {
|
|
||||||
const el = measureElRef.current
|
|
||||||
if (el) setContainerWidth(el.clientWidth)
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', onResize)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', onResize)
|
|
||||||
roRef.current?.disconnect()
|
|
||||||
roRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Photo rows in visual order — drops the header items so navigation
|
// Photo rows in visual order — drops the header items so navigation
|
||||||
// walks the grid as the user sees it. Each row has cells of length
|
// walks the grid as the user sees it. Each row has cells of length
|
||||||
@@ -490,8 +564,8 @@ export function Timeline() {
|
|||||||
const scrollEl = parentRef.current
|
const scrollEl = parentRef.current
|
||||||
if (itemIdx === undefined || !scrollEl) return
|
if (itemIdx === undefined || !scrollEl) return
|
||||||
let rowTop = 0
|
let rowTop = 0
|
||||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
|
||||||
const rowHeight = items[itemIdx].height
|
const rowHeight = effectiveHeight(items[itemIdx])
|
||||||
const viewTop = scrollEl.scrollTop
|
const viewTop = scrollEl.scrollTop
|
||||||
const viewBottom = viewTop + scrollEl.clientHeight
|
const viewBottom = viewTop + scrollEl.clientHeight
|
||||||
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
|
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
|
||||||
@@ -505,6 +579,45 @@ export function Timeline() {
|
|||||||
scrollEl.scrollTo({ top: target })
|
scrollEl.scrollTo({ top: target })
|
||||||
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
|
}, [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
|
// Flat visible-order id sequence — exactly the order the user reads
|
||||||
// off the grid (top-to-bottom, left-to-right within each row).
|
// off the grid (top-to-bottom, left-to-right within each row).
|
||||||
// Includes duplicates from tag-grouping; landing on the same photo's
|
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||||
@@ -520,197 +633,63 @@ export function Timeline() {
|
|||||||
return ids
|
return ids
|
||||||
}, [photoRows])
|
}, [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
|
// 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
|
// can walk the same order even when opened from a non-click path
|
||||||
// (e.g. the global Space hotkey).
|
// (e.g. the global Space hotkey).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisiblePhotoIds(visibleSequence)
|
setVisiblePhotoIds(visibleSequence)
|
||||||
visibleSequenceRef.current = visibleSequence
|
|
||||||
}, [visibleSequence, setVisiblePhotoIds])
|
}, [visibleSequence, setVisiblePhotoIds])
|
||||||
|
|
||||||
// Locate the active photo in the visual grid. Returns the FIRST
|
// Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with
|
||||||
// Handle keyboard shortcuts for photo navigation. Operates on the
|
// MemoriesView via useGridKeyNav. Timeline provides its own scroll
|
||||||
// grouped grid the user sees, so a half-full last row of a group
|
// callback because row geometry comes from the TanStack virtualizer's
|
||||||
// doesn't make ArrowDown skip into the wrong place.
|
// item heights; MemoriesView reads DOM offsets instead.
|
||||||
//
|
//
|
||||||
// Inert in preview mode — PreviewView mounts its own arrow handlers,
|
// Inert in preview mode (PreviewView has its own arrow handlers) and
|
||||||
// and a window-level grid handler firing alongside them used to race
|
// in the duplicates section (DuplicatesView manages its own grouped
|
||||||
// against PreviewView's setActivePhoto, landing the user on the wrong
|
// nav against a different snapshot of the visible grid).
|
||||||
// photo. The grid handler stays attached so it can re-engage the
|
const gridNavRows = useMemo(
|
||||||
// moment the user closes preview.
|
() =>
|
||||||
//
|
photoRows.map((row) => ({
|
||||||
// The handler reads its inputs through a ref so the listener can bind
|
cells: row.cells.map((c) => ({ id: c.photo.id })),
|
||||||
// once per (viewMode, currentSection) — every other dep used to be in
|
})),
|
||||||
// the array and triggered an unbind/rebind on every state tick (eight
|
[photoRows]
|
||||||
// values, several with new identity each render).
|
)
|
||||||
const navStateRef = useRef({
|
const scrollRowIntoView = useCallback(
|
||||||
photoRows,
|
(rowIdx: number) => {
|
||||||
photos,
|
const itemIdx = photoRowItemIndex[rowIdx]
|
||||||
selectedPhotos,
|
const scrollEl = parentRef.current
|
||||||
activePhotoId,
|
if (itemIdx === undefined || !scrollEl) return
|
||||||
photoRowItemIndex,
|
// Sum item heights up to itemIdx to get this row's offset in the
|
||||||
items,
|
// virtualizer's coordinate space. Cheap enough at O(items) and
|
||||||
cellSize,
|
// avoids reaching into virtualizer.measurementsCache internals.
|
||||||
|
let rowTop = 0
|
||||||
|
for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
|
||||||
|
const rowHeight = effectiveHeight(items[itemIdx])
|
||||||
|
const peek = Math.round(cellSize * 0.35)
|
||||||
|
const viewTop = scrollEl.scrollTop
|
||||||
|
const viewBottom = viewTop + scrollEl.clientHeight
|
||||||
|
if (rowTop - peek < viewTop) {
|
||||||
|
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
|
||||||
|
} else if (rowTop + rowHeight + peek > viewBottom) {
|
||||||
|
scrollEl.scrollTo({
|
||||||
|
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[photoRowItemIndex, items, cellSize]
|
||||||
|
)
|
||||||
|
useGridKeyNav({
|
||||||
|
rows: gridNavRows,
|
||||||
|
enabled: viewMode === 'grid' && currentSection !== 'duplicates',
|
||||||
|
scrollRowIntoView,
|
||||||
})
|
})
|
||||||
navStateRef.current = {
|
|
||||||
photoRows,
|
|
||||||
photos,
|
|
||||||
selectedPhotos,
|
|
||||||
activePhotoId,
|
|
||||||
photoRowItemIndex,
|
|
||||||
items,
|
|
||||||
cellSize,
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode !== 'grid') return
|
|
||||||
// The duplicates section mounts its own grouped view (DuplicatesView)
|
|
||||||
// with its own keyboard nav — bail out so we don't double-handle
|
|
||||||
// arrow keys and try to navigate against a photoRows snapshot that
|
|
||||||
// doesn't match what the user actually sees on screen.
|
|
||||||
if (currentSection === 'duplicates') return
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const {
|
|
||||||
photoRows,
|
|
||||||
photos,
|
|
||||||
selectedPhotos,
|
|
||||||
photoRowItemIndex,
|
|
||||||
items,
|
|
||||||
cellSize,
|
|
||||||
} = navStateRef.current
|
|
||||||
if (photoRows.length === 0) return
|
|
||||||
const target = e.target as HTMLElement | null
|
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const findActive = (): { row: number; col: number } | null => {
|
|
||||||
const aid = navStateRef.current.activePhotoId
|
|
||||||
if (!aid) return null
|
|
||||||
for (let r = 0; r < photoRows.length; r++) {
|
|
||||||
const c = photoRows[r].cells.findIndex((cell) => cell.photo.id === aid)
|
|
||||||
if (c >= 0) return { row: r, col: c }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const move = (dr: number, dc: number) => {
|
|
||||||
const current = findActive() ?? { row: 0, col: -1 }
|
|
||||||
let nextRow = current.row
|
|
||||||
let nextCol = current.col + dc
|
|
||||||
|
|
||||||
if (dc !== 0) {
|
|
||||||
// Wrap left/right across row boundaries.
|
|
||||||
while (nextCol < 0 && nextRow > 0) {
|
|
||||||
nextRow -= 1
|
|
||||||
nextCol = photoRows[nextRow].cells.length - 1
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
nextRow < photoRows.length &&
|
|
||||||
nextCol >= photoRows[nextRow].cells.length
|
|
||||||
) {
|
|
||||||
if (nextRow === photoRows.length - 1) {
|
|
||||||
nextCol = photoRows[nextRow].cells.length - 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
nextRow += 1
|
|
||||||
nextCol = 0
|
|
||||||
}
|
|
||||||
if (nextCol < 0) nextCol = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dr !== 0) {
|
|
||||||
nextRow += dr
|
|
||||||
if (nextRow < 0) nextRow = 0
|
|
||||||
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
|
|
||||||
// Clamp the column to the destination row's actual width so
|
|
||||||
// moving down into a half-full row lands on its last cell
|
|
||||||
// instead of nothing.
|
|
||||||
const rowLen = photoRows[nextRow].cells.length
|
|
||||||
if (nextCol >= rowLen) nextCol = rowLen - 1
|
|
||||||
if (nextCol < 0) nextCol = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const dest = photoRows[nextRow]?.cells[nextCol]
|
|
||||||
if (!dest) return
|
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRange(dest.photo.id)
|
|
||||||
} else {
|
|
||||||
selectPhoto(dest.photo.id)
|
|
||||||
}
|
|
||||||
// Bring the destination row into view if it's off-screen, leaving
|
|
||||||
// a "peek" margin so the next row above/below stays partly visible
|
|
||||||
// — cues the user that there's more content in the scroll direction.
|
|
||||||
// In-viewport moves are a no-op, so same-row arrow presses don't
|
|
||||||
// jitter the scroll position.
|
|
||||||
const itemIdx = photoRowItemIndex[nextRow]
|
|
||||||
const scrollEl = parentRef.current
|
|
||||||
if (itemIdx !== undefined && scrollEl) {
|
|
||||||
// Sum item heights up to itemIdx to get this row's offset in the
|
|
||||||
// virtualizer's coordinate space. Cheap enough at O(items) and
|
|
||||||
// avoids reaching into virtualizer.measurementsCache internals.
|
|
||||||
let rowTop = 0
|
|
||||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
|
||||||
const rowHeight = items[itemIdx].height
|
|
||||||
const peek = Math.round(cellSize * 0.35)
|
|
||||||
const viewTop = scrollEl.scrollTop
|
|
||||||
const viewBottom = viewTop + scrollEl.clientHeight
|
|
||||||
if (rowTop - peek < viewTop) {
|
|
||||||
// Destination is above (or flush with) the viewport top. Leave
|
|
||||||
// `peek` pixels of the previous row visible above it.
|
|
||||||
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
|
|
||||||
} else if (rowTop + rowHeight + peek > viewBottom) {
|
|
||||||
// Destination is below the viewport bottom. Leave `peek` pixels
|
|
||||||
// of the next row visible below it.
|
|
||||||
scrollEl.scrollTo({
|
|
||||||
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
move(-1, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
move(1, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault()
|
|
||||||
move(0, -1)
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault()
|
|
||||||
move(0, 1)
|
|
||||||
break
|
|
||||||
case 'a':
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
photos.forEach((photo) => {
|
|
||||||
if (!selectedPhotos.includes(photo.id)) {
|
|
||||||
togglePhotoSelection(photo.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
clearSelection()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [
|
|
||||||
viewMode,
|
|
||||||
currentSection,
|
|
||||||
selectRange,
|
|
||||||
selectPhoto,
|
|
||||||
togglePhotoSelection,
|
|
||||||
clearSelection,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -759,7 +738,7 @@ export function Timeline() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-auto bg-bg"
|
className="h-full overflow-auto bg-bg"
|
||||||
// Extra bottom padding so the last row clears the floating
|
// Extra bottom padding so the last row clears the floating
|
||||||
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
|
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
|
||||||
@@ -772,23 +751,6 @@ export function Timeline() {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Width sentinel — a 1px-tall normal-flow div that takes the
|
|
||||||
* full width of the inner virtualizer wrapper, which is the
|
|
||||||
* exact width rows render at. clientWidth on this is what we
|
|
||||||
* base the column count on, sidestepping any padding /
|
|
||||||
* scrollbar mismatch the parentRef-based measurement is
|
|
||||||
* vulnerable to. ResizeObserver doesn't reliably fire on
|
|
||||||
* zero-area absolute elements, so 1px tall + relative flow. */}
|
|
||||||
<div
|
|
||||||
ref={widthSentinelRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 1,
|
|
||||||
marginBottom: -1,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const item = items[virtualItem.index]
|
const item = items[virtualItem.index]
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|||||||
@@ -16,48 +16,41 @@ function Calendar({
|
|||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn('p-3', className)}
|
className={cn('p-1.5', className)}
|
||||||
classNames={{
|
classNames={{
|
||||||
months: 'flex flex-col sm:flex-row gap-4',
|
months: 'flex flex-col sm:flex-row gap-2',
|
||||||
month: 'flex flex-col gap-3',
|
month: 'flex flex-col gap-1.5',
|
||||||
// Row layout: prev-nav · dropdowns (Month / Year) · next-nav.
|
caption: 'flex items-center justify-between px-0.5 pt-0.5',
|
||||||
// Works for both button and dropdown caption layouts.
|
caption_label: 'text-xs font-medium text-text',
|
||||||
caption: 'flex items-center justify-between px-1 pt-1',
|
caption_dropdowns: 'flex items-center justify-center gap-1.5',
|
||||||
caption_label: 'text-sm font-medium text-text',
|
dropdown_month:
|
||||||
// Dropdown caption — react-day-picker v8 renders each dropdown
|
'relative inline-flex items-center rounded px-0.5 text-xs font-medium text-text hover:bg-surface-2',
|
||||||
// as an invisible <select> layered over a visible caption_label
|
dropdown_year:
|
||||||
// span. We position the <select> absolutely + transparent so
|
'relative inline-flex items-center rounded px-0.5 text-xs font-medium text-text hover:bg-surface-2',
|
||||||
// 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',
|
|
||||||
dropdown:
|
dropdown:
|
||||||
'absolute inset-0 z-10 cursor-pointer appearance-none bg-transparent opacity-0',
|
'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:
|
vhidden:
|
||||||
'!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]',
|
'!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(
|
nav_button: cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
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_previous: '',
|
||||||
nav_button_next: '',
|
nav_button_next: '',
|
||||||
table: 'w-full border-collapse',
|
table: 'w-full border-collapse',
|
||||||
head_row: 'flex',
|
head_row: 'flex',
|
||||||
head_cell:
|
head_cell:
|
||||||
'text-text-muted rounded-md w-7 font-normal text-[0.7rem]',
|
'text-text-muted rounded w-6 font-normal text-[9px] uppercase tracking-wide',
|
||||||
row: 'flex w-full mt-1',
|
row: 'flex w-full mt-0.5',
|
||||||
cell: cn(
|
cell: cn(
|
||||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20',
|
'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'
|
'[&:has([aria-selected])]:bg-surface-2 [&:has([aria-selected].day-outside)]:bg-surface-2/50',
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
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_range_end: 'day-range-end',
|
||||||
day_selected:
|
day_selected:
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ const Toaster = ({ ...props }: ToasterProps) => (
|
|||||||
<Sonner
|
<Sonner
|
||||||
position="bottom-left"
|
position="bottom-left"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
|
// Compact toasts: tight padding, smaller gap, single-line title+desc
|
||||||
|
// so they don't hog the corner of the screen.
|
||||||
|
gap={6}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
'group toast bg-surface/90 border border-border backdrop-blur-md text-text shadow-lg',
|
'group toast !gap-1.5 !p-2 !min-h-0 bg-surface/90 border border-border backdrop-blur-md text-text shadow',
|
||||||
title: 'text-text text-sm font-semibold',
|
title: 'text-text text-xs font-medium leading-tight',
|
||||||
description: 'text-text-muted text-xs',
|
description: 'text-text-muted text-[11px] leading-tight',
|
||||||
actionButton:
|
actionButton:
|
||||||
'bg-primary text-bg hover:bg-primary/90 rounded-md px-2 py-1 text-xs font-medium',
|
'!h-6 bg-primary text-bg hover:bg-primary/90 rounded-md px-1.5 text-[11px] font-medium',
|
||||||
cancelButton:
|
cancelButton:
|
||||||
'bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-2 py-1 text-xs',
|
'!h-6 bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-1.5 text-[11px]',
|
||||||
|
icon: '!size-3.5 !mr-1',
|
||||||
success: 'border-l-2 border-l-pick',
|
success: 'border-l-2 border-l-pick',
|
||||||
error: 'border-l-2 border-l-reject',
|
error: 'border-l-2 border-l-reject',
|
||||||
info: 'border-l-2 border-l-primary',
|
info: 'border-l-2 border-l-primary',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from '../components/ToastContainer'
|
|||||||
import { formatApiError } from '../lib/apiError'
|
import { formatApiError } from '../lib/apiError'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralised bulk-mutation hook used by both the RightSidebar multi-
|
* Centralised bulk-mutation hook used by both the RightSidebar multi-
|
||||||
@@ -48,6 +49,22 @@ export function useBulkPhotoMutations() {
|
|||||||
const single = queryClient.getQueryData<Photo>(['photo', id])
|
const single = queryClient.getQueryData<Photo>(['photo', id])
|
||||||
if (single) snap.set(id, { ...single })
|
if (single) snap.set(id, { ...single })
|
||||||
}
|
}
|
||||||
|
// Fall back to memories cache for photos only surfaced via
|
||||||
|
// "On this day" — otherwise a rollback on error can't restore them.
|
||||||
|
const mem = queryClient.getQueryData<MemoriesResponse>(['memories'])
|
||||||
|
if (mem) {
|
||||||
|
for (const g of mem.memories) {
|
||||||
|
for (const p of g.photos) {
|
||||||
|
if (want.has(p.id) && !snap.has(p.id)) {
|
||||||
|
snap.set(p.id, {
|
||||||
|
rating: p.rating,
|
||||||
|
color_label: p.color_label,
|
||||||
|
is_discarded: p.is_discarded,
|
||||||
|
} as Partial<Photo>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return snap
|
return snap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +79,27 @@ export function useBulkPhotoMutations() {
|
|||||||
const cur = queryClient.getQueryData<Photo>(['photo', id])
|
const cur = queryClient.getQueryData<Photo>(['photo', id])
|
||||||
if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
|
if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
|
||||||
}
|
}
|
||||||
|
// Memories cache has a nested {memories:[{photos:[...]}]} shape —
|
||||||
|
// only the fields MemoryPhoto carries (rating/color_label/is_discarded)
|
||||||
|
// can be patched in-place, which covers every culling shortcut.
|
||||||
|
const memPatch: Partial<MemoryPhoto> = {}
|
||||||
|
if (patch.rating !== undefined) memPatch.rating = patch.rating
|
||||||
|
if (patch.color_label !== undefined) memPatch.color_label = patch.color_label
|
||||||
|
if (patch.is_discarded !== undefined) memPatch.is_discarded = patch.is_discarded
|
||||||
|
if (Object.keys(memPatch).length === 0) return
|
||||||
|
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
memories: prev.memories.map((g) => ({
|
||||||
|
...g,
|
||||||
|
photos: g.photos.map((p) =>
|
||||||
|
want.has(p.id) ? { ...p, ...memPatch } : p,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
|
const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
|
||||||
@@ -81,6 +119,27 @@ export function useBulkPhotoMutations() {
|
|||||||
queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
|
queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
memories: prev.memories.map((g) => ({
|
||||||
|
...g,
|
||||||
|
photos: g.photos.map((p) => {
|
||||||
|
const orig = snap.get(p.id)
|
||||||
|
if (!orig) return p
|
||||||
|
const out: MemoryPhoto = { ...p }
|
||||||
|
if (orig.rating !== undefined) out.rating = orig.rating
|
||||||
|
if (orig.color_label !== undefined)
|
||||||
|
out.color_label = orig.color_label ?? null
|
||||||
|
if (orig.is_discarded !== undefined)
|
||||||
|
out.is_discarded = orig.is_discarded
|
||||||
|
return out
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkRating = useMutation({
|
const bulkRating = useMutation({
|
||||||
|
|||||||
161
frontend/src/hooks/useGridKeyNav.ts
Normal file
161
frontend/src/hooks/useGridKeyNav.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
|
|
||||||
|
/** A single row of the visual grid. Only the photo id is required —
|
||||||
|
* callers (Timeline, MemoriesView) can keep richer cell shapes but we
|
||||||
|
* only care about id for navigation. */
|
||||||
|
export interface GridNavRow {
|
||||||
|
cells: { id: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGridKeyNavArgs {
|
||||||
|
/** The flat, in-order grid rows the user sees. Navigation wraps at
|
||||||
|
* row ends and clamps to the destination row's width on vertical
|
||||||
|
* moves so half-full trailing rows don't land on empty cells. */
|
||||||
|
rows: GridNavRow[]
|
||||||
|
/** Gate the listener. Typically `viewMode === 'grid'` AND the view
|
||||||
|
* is mounted. When false, the window keydown listener is detached. */
|
||||||
|
enabled: boolean
|
||||||
|
/** Called after a successful arrow-nav move so the caller can scroll
|
||||||
|
* the destination row into view. No-op moves (same row, within
|
||||||
|
* viewport) still call it — the callback decides whether to scroll. */
|
||||||
|
scrollRowIntoView?: (rowIdx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared arrow/Ctrl+A/Escape grid keyboard handler. Centralises the
|
||||||
|
* navigation logic Timeline and MemoriesView both need so they stay in
|
||||||
|
* lockstep — any future grid view can opt in by supplying its own
|
||||||
|
* `rows` and (optionally) a scroll callback.
|
||||||
|
*
|
||||||
|
* Scroll math stays with the caller because each grid measures row
|
||||||
|
* geometry differently (TanStack virtualizer for Timeline; native DOM
|
||||||
|
* offsets for the non-virtualised MemoriesView).
|
||||||
|
*/
|
||||||
|
export function useGridKeyNav({
|
||||||
|
rows,
|
||||||
|
enabled,
|
||||||
|
scrollRowIntoView,
|
||||||
|
}: UseGridKeyNavArgs) {
|
||||||
|
// Read inputs through a ref so the listener binds once per `enabled`
|
||||||
|
// toggle — without this the handler would re-attach on every render,
|
||||||
|
// churning the window event map.
|
||||||
|
const stateRef = useRef({ rows, scrollRowIntoView })
|
||||||
|
stateRef.current = { rows, scrollRowIntoView }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const { rows, scrollRowIntoView } = stateRef.current
|
||||||
|
if (rows.length === 0) return
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = usePhotoStore.getState()
|
||||||
|
|
||||||
|
const findActive = (): { row: number; col: number } | null => {
|
||||||
|
const aid = store.activePhotoId
|
||||||
|
if (!aid) return null
|
||||||
|
for (let r = 0; r < rows.length; r++) {
|
||||||
|
const c = rows[r].cells.findIndex((cell) => cell.id === aid)
|
||||||
|
if (c >= 0) return { row: r, col: c }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (dr: number, dc: number) => {
|
||||||
|
const current = findActive() ?? { row: 0, col: -1 }
|
||||||
|
let nextRow = current.row
|
||||||
|
let nextCol = current.col + dc
|
||||||
|
|
||||||
|
if (dc !== 0) {
|
||||||
|
// Wrap left/right across row boundaries.
|
||||||
|
while (nextCol < 0 && nextRow > 0) {
|
||||||
|
nextRow -= 1
|
||||||
|
nextCol = rows[nextRow].cells.length - 1
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
nextRow < rows.length &&
|
||||||
|
nextCol >= rows[nextRow].cells.length
|
||||||
|
) {
|
||||||
|
if (nextRow === rows.length - 1) {
|
||||||
|
nextCol = rows[nextRow].cells.length - 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextRow += 1
|
||||||
|
nextCol = 0
|
||||||
|
}
|
||||||
|
if (nextCol < 0) nextCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dr !== 0) {
|
||||||
|
nextRow += dr
|
||||||
|
if (nextRow < 0) nextRow = 0
|
||||||
|
if (nextRow >= rows.length) nextRow = rows.length - 1
|
||||||
|
// Clamp the column to the destination row's actual width so
|
||||||
|
// moving down into a half-full row lands on its last cell
|
||||||
|
// instead of nothing.
|
||||||
|
const rowLen = rows[nextRow].cells.length
|
||||||
|
if (nextCol >= rowLen) nextCol = rowLen - 1
|
||||||
|
if (nextCol < 0) nextCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = rows[nextRow]?.cells[nextCol]
|
||||||
|
if (!dest) return
|
||||||
|
|
||||||
|
// Read fresh actions each call so the hook stays zero-dep.
|
||||||
|
const { selectRange, selectPhoto } = usePhotoStore.getState()
|
||||||
|
if (e.shiftKey) selectRange(dest.id)
|
||||||
|
else selectPhoto(dest.id)
|
||||||
|
|
||||||
|
scrollRowIntoView?.(nextRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
move(-1, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
move(1, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
move(0, -1)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
move(0, 1)
|
||||||
|
break
|
||||||
|
case 'a':
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { selectedPhotos, togglePhotoSelection } =
|
||||||
|
usePhotoStore.getState()
|
||||||
|
const selected = new Set(selectedPhotos)
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
if (!selected.has(cell.id)) togglePhotoSelection(cell.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
usePhotoStore.getState().clearSelection()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [enabled])
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
|||||||
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
|
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
|
||||||
import { formatApiError } from '../lib/apiError'
|
import { formatApiError } from '../lib/apiError'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -76,6 +77,21 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
|
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
|
||||||
: prev
|
: prev
|
||||||
)
|
)
|
||||||
|
// Memories view has its own cache shape ({memories: [{photos: [...]}]})
|
||||||
|
// — patch it too so the grayscale flip shows up in "On this day".
|
||||||
|
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
memories: prev.memories.map((g) => ({
|
||||||
|
...g,
|
||||||
|
photos: g.photos.map((p) =>
|
||||||
|
set.has(p.id) ? { ...p, is_discarded: discarded } : p,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a photo's CURRENT cached state (is_discarded etc) without
|
/** Look up a photo's CURRENT cached state (is_discarded etc) without
|
||||||
@@ -88,9 +104,35 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
const p = list.find((x) => x.id === id)
|
const p = list.find((x) => x.id === id)
|
||||||
if (p) return p
|
if (p) return p
|
||||||
}
|
}
|
||||||
|
// Fall through to memories cache (On this day) and then per-photo.
|
||||||
|
const mem = queryClient.getQueryData<MemoriesResponse>(['memories'])
|
||||||
|
if (mem) {
|
||||||
|
for (const g of mem.memories) {
|
||||||
|
const m = g.photos.find((p: MemoryPhoto) => p.id === id)
|
||||||
|
if (m) return memoryToPhotoPartial(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
return queryClient.getQueryData<Photo>(['photo', id])
|
return queryClient.getQueryData<Photo>(['photo', id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const memoryToPhotoPartial = (m: MemoryPhoto): Photo =>
|
||||||
|
({
|
||||||
|
id: m.id,
|
||||||
|
filepath: m.filename,
|
||||||
|
filename: m.filename,
|
||||||
|
media_type: m.media_type,
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
taken_at: m.taken_at,
|
||||||
|
rating: m.rating,
|
||||||
|
color_label: m.color_label,
|
||||||
|
is_discarded: m.is_discarded,
|
||||||
|
is_duplicate: false,
|
||||||
|
file_hash: '',
|
||||||
|
folder_id: null,
|
||||||
|
added_at: null,
|
||||||
|
} as Photo)
|
||||||
|
|
||||||
// Discard / restore mutation — the only path that doesn't auto-
|
// Discard / restore mutation — the only path that doesn't auto-
|
||||||
// invalidate ['photos']. Invalidating would refetch with the active
|
// invalidate ['photos']. Invalidating would refetch with the active
|
||||||
// filter (which excludes discarded photos in every section except
|
// filter (which excludes discarded photos in every section except
|
||||||
|
|||||||
@@ -600,6 +600,8 @@ export interface MemoryPhoto {
|
|||||||
width: number | null
|
width: number | null
|
||||||
height: number | null
|
height: number | null
|
||||||
rating: number
|
rating: number
|
||||||
|
color_label: string | null
|
||||||
|
is_discarded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryGroup {
|
export interface MemoryGroup {
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ interface PhotoStore {
|
|||||||
* on the photo they came from instead of whatever neighbour they
|
* on the photo they came from instead of whatever neighbour they
|
||||||
* arrow-navigated to inside the preview. */
|
* arrow-navigated to inside the preview. */
|
||||||
previewOriginPhotoId: string | null
|
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
|
setPhotos: (photos: Photo[]) => void
|
||||||
selectPhoto: (id: string) => void
|
selectPhoto: (id: string) => void
|
||||||
@@ -55,6 +66,11 @@ interface PhotoStore {
|
|||||||
* to the next surviving neighbour so the grid still has a target
|
* to the next surviving neighbour so the grid still has a target
|
||||||
* for arrow keys after the removal. */
|
* for arrow keys after the removal. */
|
||||||
removePhotosFromTimeline: (ids: string[]) => void
|
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) => ({
|
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||||
@@ -65,9 +81,26 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
visiblePhotoIds: [],
|
visiblePhotoIds: [],
|
||||||
previewOriginPhotoId: null,
|
previewOriginPhotoId: null,
|
||||||
|
visibleAnchorDate: null,
|
||||||
|
jumpTargetId: null,
|
||||||
|
|
||||||
setPhotos: (photos) => set({ photos }),
|
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({
|
selectPhoto: (id) => set({
|
||||||
selectedPhotos: [id],
|
selectedPhotos: [id],
|
||||||
activePhotoId: id,
|
activePhotoId: id,
|
||||||
|
|||||||
Reference in New Issue
Block a user