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:
2026-04-10 00:37:33 +02:00
parent b2ebf401bb
commit f01b5ed77e
9 changed files with 290 additions and 38 deletions

View File

@@ -1,9 +1,25 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api'
import type { Photo } from '../types/photo'
/**
* Optimistically strip the supplied photo ids from every cached
* timeline list. Used by discard / delete flows so the photos vanish
* from the grid the instant the user acts, without waiting for the
* mutation round-trip + invalidation refetch. The subsequent invalidate
* still runs and reconciles the cache with server truth.
*/
export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) {
if (ids.length === 0) return
const removed = new Set(ids)
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) => {
if (!prev) return prev
return prev.filter((p) => !removed.has(p.id))
})
}
/**
* Single source of truth for the timeline photos query. Both Timeline and
* PreviewView call this so they share one cache entry — previously
@@ -47,39 +63,67 @@ export function usePhotosQuery() {
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
)
const queryClient = useQueryClient()
return useQuery({
queryKey: ['photos', filterParams],
queryFn: async () => {
// Goes through the shared axios instance so it inherits the
// relative /api/v1 baseURL — same-origin behind the nginx / vite
// proxy, no CORS dance required from another machine.
//
// The Timeline and grid views virtualize, so we load every match
// up-front rather than paginating in the UI. Backend caps per_page
// at 500, so for libraries / folders with more matches we walk
// pages until we have everything. Capped at 200 pages (= 100k
// photos) as a sanity bound.
queryFn: async ({ signal }) => {
// Two-phase fetch so the timeline can paint its first thumbnails
// long before the entire library has finished downloading. Phase 1
// returns the first page synchronously (which resolves the
// useQuery promise so consumers exit their loading state). Phase 2
// walks the remaining pages in the background, appending each one
// into the cache via setQueryData so the grid grows as data
// arrives. The signal from React Query aborts the background
// loop if the query is invalidated or unmounts mid-stream.
const PER_PAGE = 500
const MAX_PAGES = 200
const all: Photo[] = []
for (let page = 1; page <= MAX_PAGES; page++) {
const response = await api.get<{
photos: Photo[]
total: number
pages: number
}>('/photos', {
params: {
page,
per_page: PER_PAGE,
...filterParams,
},
})
const photos = response.data.photos || []
all.push(...photos)
const totalPages = response.data.pages ?? 1
if (page >= totalPages || photos.length < PER_PAGE) break
const firstResp = await api.get<{
photos: Photo[]
total: number
pages: number
}>('/photos', {
params: { page: 1, per_page: PER_PAGE, ...filterParams },
signal,
})
const firstBatch = firstResp.data.photos || []
const totalPages = firstResp.data.pages ?? 1
if (totalPages > 1 && firstBatch.length === PER_PAGE) {
// Fire-and-forget background loop. We don't await here — the
// first batch is already enough to render. Each subsequent
// page lands via setQueryData, which triggers consumers to
// re-render with the larger list.
void (async () => {
for (let page = 2; page <= Math.min(totalPages, MAX_PAGES); page++) {
if (signal?.aborted) return
try {
const resp = await api.get<{
photos: Photo[]
total: number
pages: number
}>('/photos', {
params: { page, per_page: PER_PAGE, ...filterParams },
signal,
})
if (signal?.aborted) return
const more = resp.data.photos || []
queryClient.setQueryData<Photo[]>(
['photos', filterParams],
(prev) => (prev ? [...prev, ...more] : more)
)
if (more.length < PER_PAGE) return
} catch {
// Network or abort — give up the background stream. The
// next user-triggered refetch will start fresh.
return
}
}
})()
}
return all
return firstBatch
},
staleTime: 30_000,
})