feat: snappier timeline — instant discard, progressive load, restore preview origin
- Discard yanks photos from the grid optimistically (cache strip + active cursor advance) instead of waiting for the mutation round-trip; wired through the X hotkey, RightSidebar bulk discard, LeftSidebar discard drop, and DiscardActionBar restore/delete. - usePhotosQuery resolves on the first 500-photo page and streams the remaining pages into the cache in the background, so the first thumbnails paint immediately on large libraries. - Closing preview restores the photo it was opened on (snapshot ref in PreviewView, written directly to the store) and Timeline scrolls that row back into view. Escape is handled on the dialog with stopPropagation so Timeline's window-level Esc handler doesn't wipe the restored selection. - Preview overlay bumped to z-[1000] so it covers Leaflet map tiles, and the right sidebar no longer collapses during preview — both fix visible layout shifts on close. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { usePhotosQuery, stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import { discard as discardApi, photos as photosApi } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||
@@ -27,6 +27,14 @@ export function DiscardActionBar() {
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
||||
// Pull the restored ids out of the discard view immediately. The
|
||||
// user is sitting on flag=discarded so they should disappear from
|
||||
// sight the moment the click lands; the onSuccess invalidate still
|
||||
// reconciles with server truth shortly after.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: (_, ids) => {
|
||||
registerUndoable(
|
||||
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||
@@ -45,6 +53,10 @@ export function DiscardActionBar() {
|
||||
|
||||
const deleteSelectedMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
const count = data?.deleted ?? 0
|
||||
const errors = data?.file_errors ?? 0
|
||||
|
||||
@@ -28,6 +28,8 @@ import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import {
|
||||
useLibraryStatsQuery,
|
||||
LIBRARY_STATS_QUERY_KEY,
|
||||
@@ -101,6 +103,13 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||
const discardDropMutation = useMutation({
|
||||
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||
// Optimistically pull the dropped photos out of the timeline so the
|
||||
// grid reflows the moment the drop lands, instead of waiting for
|
||||
// the network round-trip + invalidation refetch.
|
||||
onMutate: (photoIds) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(photoIds)
|
||||
stripPhotosFromCache(queryClient, photoIds)
|
||||
},
|
||||
onSuccess: (_data, photoIds) => {
|
||||
registerUndoable(
|
||||
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
@@ -48,6 +49,13 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
||||
})
|
||||
const bulkDiscardMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||
// Yank the photos from the timeline before the network round-trip
|
||||
// so the grid reflows immediately. Same pattern as the X hotkey
|
||||
// path in useKeyboardShortcuts.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,13 +14,38 @@ import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
export function PreviewView() {
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||
|
||||
// Snapshot the photo we opened on, captured once at mount via the
|
||||
// store's getState (which is guaranteed to reflect the value the
|
||||
// openPreview action just wrote, even if the React subscription
|
||||
// hasn't been delivered to this component's first render yet). This
|
||||
// is the id we'll restore on close, no matter how many neighbours
|
||||
// the user arrows through inside the preview.
|
||||
const openOriginRef = useRef<string | null>(
|
||||
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
||||
)
|
||||
const closePreview = useCallback(() => {
|
||||
// Bypass the store action and write the restoration directly so
|
||||
// the snapshot ref is the single source of truth. Falls back to
|
||||
// the live activePhotoId if the ref was somehow never populated
|
||||
// (defensive — openPreview always sets activePhotoId before
|
||||
// PreviewView mounts).
|
||||
const id =
|
||||
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
|
||||
usePhotoStore.setState({
|
||||
viewMode: 'grid',
|
||||
activePhotoId: id,
|
||||
rangeStartId: id,
|
||||
selectedPhotos: id ? [id] : [],
|
||||
previewOriginPhotoId: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||
// it up by key (which broke when the key gained the filter params).
|
||||
const { data: rawPhotos = [] } = usePhotosQuery()
|
||||
@@ -93,7 +118,16 @@ export function PreviewView() {
|
||||
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
||||
// The handlers themselves are stable (refs internally) so the deps
|
||||
// array stays empty — useHotkeys won't have to re-bind on every render.
|
||||
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||
//
|
||||
// Escape is intentionally NOT bound through useHotkeys here. The
|
||||
// grid-level Timeline component listens for Escape on `window` to
|
||||
// clear the current selection, and react-hotkeys-hook binds at
|
||||
// document level — so a single Esc keypress would land in BOTH
|
||||
// handlers. The grid handler would then wipe the selection we just
|
||||
// restored from the preview origin. Instead, escape is handled by
|
||||
// the dialog's onKeyDown below, which runs first (lower in the
|
||||
// bubble chain) and calls stopPropagation so the keypress never
|
||||
// reaches window.
|
||||
useHotkeys('left', goPrev, { preventDefault: true })
|
||||
useHotkeys('right', goNext, { preventDefault: true })
|
||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||
@@ -123,8 +157,17 @@ export function PreviewView() {
|
||||
}, [])
|
||||
|
||||
// Trap Tab inside the dialog so users can't accidentally tab into the
|
||||
// hidden grid behind. Simple cycle implementation.
|
||||
// hidden grid behind. Simple cycle implementation. Also intercepts
|
||||
// Escape and stops propagation before the keypress reaches the
|
||||
// window-level handler in Timeline (which would otherwise wipe the
|
||||
// selection we just restored to the entry photo).
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const root = containerRef.current
|
||||
if (!root) return
|
||||
@@ -156,7 +199,7 @@ export function PreviewView() {
|
||||
aria-modal="true"
|
||||
aria-label="Photo preview"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
>
|
||||
<div>No photo to display</div>
|
||||
<button
|
||||
@@ -177,7 +220,7 @@ export function PreviewView() {
|
||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||
className="fixed inset-0 z-[1000] flex bg-black outline-none"
|
||||
>
|
||||
{/* Main column — image + filmstrip */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
|
||||
@@ -321,6 +321,11 @@ export function Timeline() {
|
||||
// they share one cache entry, regardless of filter state.
|
||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||
|
||||
// Tracks the previous viewMode so the "preview just closed" scroll
|
||||
// effect (defined further down, after photoRows) only fires on the
|
||||
// actual transition rather than every items[] recomputation.
|
||||
const prevViewModeRef = useRef(viewMode)
|
||||
|
||||
// Auto-focus the first photo on initial grid load so arrow-key nav
|
||||
// works immediately without a pre-click. Only fires when there's no
|
||||
// current active photo — we never clobber the user's selection or
|
||||
@@ -459,6 +464,44 @@ export function Timeline() {
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
// After the preview closes, scroll the photo it was originally
|
||||
// opened on back into view. The store's closePreview already
|
||||
// restored activePhotoId to that origin id; we just need to make
|
||||
// sure it's actually visible in the scroll viewport. Guarded by
|
||||
// prevViewModeRef so this only fires on the actual preview→grid
|
||||
// transition, not every time photoRows recomputes.
|
||||
useEffect(() => {
|
||||
const prev = prevViewModeRef.current
|
||||
prevViewModeRef.current = viewMode
|
||||
if (prev !== 'preview' || viewMode !== 'grid') return
|
||||
if (!activePhotoId) return
|
||||
let rowIdx = -1
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) {
|
||||
rowIdx = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rowIdx < 0) return
|
||||
const itemIdx = photoRowItemIndex[rowIdx]
|
||||
const scrollEl = parentRef.current
|
||||
if (itemIdx === undefined || !scrollEl) return
|
||||
let rowTop = 0
|
||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
||||
const rowHeight = items[itemIdx].height
|
||||
const viewTop = scrollEl.scrollTop
|
||||
const viewBottom = viewTop + scrollEl.clientHeight
|
||||
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
|
||||
// Center the row in the viewport — the user is returning to a
|
||||
// specific photo, not resuming a scroll, so context above and
|
||||
// below is what they want.
|
||||
const target = Math.max(
|
||||
0,
|
||||
rowTop - scrollEl.clientHeight / 2 + rowHeight / 2
|
||||
)
|
||||
scrollEl.scrollTo({ top: target })
|
||||
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
|
||||
|
||||
// Flat visible-order id sequence — exactly the order the user reads
|
||||
// off the grid (top-to-bottom, left-to-right within each row).
|
||||
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||
|
||||
Reference in New Issue
Block a user