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

@@ -42,7 +42,11 @@ function App() {
// Right sidebar stays open by default and shows whatever's selected // Right sidebar stays open by default and shows whatever's selected
// (or an empty state if nothing is). User can still toggle it manually. // (or an empty state if nothing is). User can still toggle it manually.
const showRightSidebar = rightSidebarOpen && viewMode === 'grid' // Note: deliberately NOT gated on viewMode — the preview overlay sits
// on top with z-[1000], so leaving the sidebar mounted underneath
// costs nothing visually and avoids the collapse-then-reopen layout
// shift the user would otherwise see every time they exit preview.
const showRightSidebar = rightSidebarOpen
return ( return (
<div className="flex flex-col h-screen bg-bg text-text"> <div className="flex flex-col h-screen bg-bg text-text">

View File

@@ -3,7 +3,7 @@ import { RotateCcw, Trash2 } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore' 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 { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog' import { ConfirmDialog } from '../dialogs/ConfirmDialog'
@@ -27,6 +27,14 @@ export function DiscardActionBar() {
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.restore(ids), 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) => { onSuccess: (_, ids) => {
registerUndoable( registerUndoable(
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`, `Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
@@ -45,6 +53,10 @@ export function DiscardActionBar() {
const deleteSelectedMutation = useMutation({ const deleteSelectedMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids), mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
onMutate: (ids) => {
usePhotoStore.getState().removePhotosFromTimeline(ids)
stripPhotosFromCache(queryClient, ids)
},
onSuccess: (data: any) => { onSuccess: (data: any) => {
const count = data?.deleted ?? 0 const count = data?.deleted ?? 0
const errors = data?.file_errors ?? 0 const errors = data?.file_errors ?? 0

View File

@@ -28,6 +28,8 @@ import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import { usePhotoStore } from '../../store/photoStore'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { import {
useLibraryStatsQuery, useLibraryStatsQuery,
LIBRARY_STATS_QUERY_KEY, LIBRARY_STATS_QUERY_KEY,
@@ -101,6 +103,13 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
// Bulk discard mutation for the drag-onto-Discarded interaction. // Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({ const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds), 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) => { onSuccess: (_data, photoIds) => {
registerUndoable( registerUndoable(
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`, `Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,

View File

@@ -12,6 +12,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
@@ -48,6 +49,13 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
}) })
const bulkDiscardMutation = useMutation({ const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), 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, onSuccess: invalidatePhotoQueries,
}) })

View File

@@ -14,13 +14,38 @@ import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
export function PreviewView() { export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId) const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto) const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
const closePreview = usePhotoStore((s) => s.closePreview)
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds) const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null) const previouslyFocusedRef = useRef<HTMLElement | null>(null)
const [infoPanelOpen, setInfoPanelOpen] = useState(false) 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 // 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). // it up by key (which broke when the key gained the filter params).
const { data: rawPhotos = [] } = usePhotosQuery() const { data: rawPhotos = [] } = usePhotosQuery()
@@ -93,7 +118,16 @@ export function PreviewView() {
// Preview-scoped hotkeys: only mounted while PreviewView is rendered. // Preview-scoped hotkeys: only mounted while PreviewView is rendered.
// The handlers themselves are stable (refs internally) so the deps // The handlers themselves are stable (refs internally) so the deps
// array stays empty — useHotkeys won't have to re-bind on every render. // 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('left', goPrev, { preventDefault: true })
useHotkeys('right', goNext, { preventDefault: true }) useHotkeys('right', goNext, { preventDefault: true })
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { 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 // 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) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
closePreview()
return
}
if (e.key !== 'Tab') return if (e.key !== 'Tab') return
const root = containerRef.current const root = containerRef.current
if (!root) return if (!root) return
@@ -156,7 +199,7 @@ export function PreviewView() {
aria-modal="true" aria-modal="true"
aria-label="Photo preview" aria-label="Photo preview"
tabIndex={-1} 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> <div>No photo to display</div>
<button <button
@@ -177,7 +220,7 @@ export function PreviewView() {
aria-label={`Photo preview: ${currentPhoto.filename}`} aria-label={`Photo preview: ${currentPhoto.filename}`}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} 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 */} {/* Main column — image + filmstrip */}
<div className="relative flex min-w-0 flex-1 flex-col"> <div className="relative flex min-w-0 flex-1 flex-col">

View File

@@ -321,6 +321,11 @@ export function Timeline() {
// they share one cache entry, regardless of filter state. // they share one cache entry, regardless of filter state.
const { data: photos = [], isLoading } = usePhotosQuery() 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 // 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 // works immediately without a pre-click. Only fires when there's no
// current active photo — we never clobber the user's selection or // current active photo — we never clobber the user's selection or
@@ -459,6 +464,44 @@ export function Timeline() {
return map return map
}, [items]) }, [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 // 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

View File

@@ -6,6 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer' import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore' import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { stripPhotosFromCache } from './usePhotosQuery'
interface KeyboardShortcutsProps { interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void onToggleLeftSidebar: () => void
@@ -108,6 +109,17 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const ids = cullTargets() const ids = cullTargets()
if (ids.length === 0) return if (ids.length === 0) return
// Discard is a removal from the timeline view: yank it from the
// selection / cache before the network round-trip so the grid
// reflows immediately and the next photo takes over the active
// cursor. Restore goes through the same removal path because it
// only fires from views (discard pile) where restored photos no
// longer match the filter.
if (data.is_discarded === true || data.is_discarded === false) {
usePhotoStore.getState().removePhotosFromTimeline(ids)
stripPhotosFromCache(queryClient, ids)
}
if (ids.length === 1) { if (ids.length === 1) {
const id = ids[0] const id = ids[0]
updateMutation.mutate( updateMutation.mutate(

View File

@@ -1,9 +1,25 @@
import { useMemo } from 'react' 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 { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api' import api from '../services/api'
import type { Photo } from '../types/photo' 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 * Single source of truth for the timeline photos query. Both Timeline and
* PreviewView call this so they share one cache entry — previously * 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] [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
) )
const queryClient = useQueryClient()
return useQuery({ return useQuery({
queryKey: ['photos', filterParams], queryKey: ['photos', filterParams],
queryFn: async () => { queryFn: async ({ signal }) => {
// Goes through the shared axios instance so it inherits the // Two-phase fetch so the timeline can paint its first thumbnails
// relative /api/v1 baseURL — same-origin behind the nginx / vite // long before the entire library has finished downloading. Phase 1
// proxy, no CORS dance required from another machine. // returns the first page synchronously (which resolves the
// // useQuery promise so consumers exit their loading state). Phase 2
// The Timeline and grid views virtualize, so we load every match // walks the remaining pages in the background, appending each one
// up-front rather than paginating in the UI. Backend caps per_page // into the cache via setQueryData so the grid grows as data
// at 500, so for libraries / folders with more matches we walk // arrives. The signal from React Query aborts the background
// pages until we have everything. Capped at 200 pages (= 100k // loop if the query is invalidated or unmounts mid-stream.
// photos) as a sanity bound.
const PER_PAGE = 500 const PER_PAGE = 500
const MAX_PAGES = 200 const MAX_PAGES = 200
const all: Photo[] = []
for (let page = 1; page <= MAX_PAGES; page++) { const firstResp = await api.get<{
const response = await api.get<{ photos: Photo[]
photos: Photo[] total: number
total: number pages: number
pages: number }>('/photos', {
}>('/photos', { params: { page: 1, per_page: PER_PAGE, ...filterParams },
params: { signal,
page, })
per_page: PER_PAGE, const firstBatch = firstResp.data.photos || []
...filterParams, const totalPages = firstResp.data.pages ?? 1
},
}) if (totalPages > 1 && firstBatch.length === PER_PAGE) {
const photos = response.data.photos || [] // Fire-and-forget background loop. We don't await here — the
all.push(...photos) // first batch is already enough to render. Each subsequent
const totalPages = response.data.pages ?? 1 // page lands via setQueryData, which triggers consumers to
if (page >= totalPages || photos.length < PER_PAGE) break // 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, staleTime: 30_000,
}) })

View File

@@ -20,6 +20,11 @@ interface PhotoStore {
* for both preview navigation order and range selection. Owned by * for both preview navigation order and range selection. Owned by
* the Timeline component. */ * the Timeline component. */
visiblePhotoIds: string[] visiblePhotoIds: string[]
/** The id of the photo the preview was originally opened on. Captured
* by openPreview, restored by closePreview, so the user lands back
* on the photo they came from instead of whatever neighbour they
* arrow-navigated to inside the preview. */
previewOriginPhotoId: string | null
setPhotos: (photos: Photo[]) => void setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string) => void selectPhoto: (id: string) => void
@@ -38,7 +43,18 @@ interface PhotoStore {
* timeline; passing it from the click site avoids a race where the * timeline; passing it from the click site avoids a race where the
* passive Timeline publisher hasn't updated yet. */ * passive Timeline publisher hasn't updated yet. */
openPreview: (id: string, visibleSequence?: string[]) => void openPreview: (id: string, visibleSequence?: string[]) => void
closePreview: () => void /** Close the preview overlay. The optional restoreId pins the active
* photo to a specific id (typically the photo preview was opened on)
* so the user lands back on it instead of whatever neighbour they
* arrow-navigated to inside the preview. PreviewView passes the
* value it captured at mount time, which is the most reliable
* source — the in-store previewOriginPhotoId is a fallback. */
closePreview: (restoreId?: string | null) => void
/** Drop photos that just left the timeline (e.g. discarded). Strips
* them from selection / visible sequence and advances activePhotoId
* to the next surviving neighbour so the grid still has a target
* for arrow keys after the removal. */
removePhotosFromTimeline: (ids: string[]) => void
} }
export const usePhotoStore = create<PhotoStore>((set) => ({ export const usePhotoStore = create<PhotoStore>((set) => ({
@@ -48,6 +64,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
rangeStartId: null, rangeStartId: null,
viewMode: 'grid', viewMode: 'grid',
visiblePhotoIds: [], visiblePhotoIds: [],
previewOriginPhotoId: null,
setPhotos: (photos) => set({ photos }), setPhotos: (photos) => set({ photos }),
@@ -147,6 +164,9 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
set((s) => ({ set((s) => ({
viewMode: 'preview', viewMode: 'preview',
activePhotoId: id, activePhotoId: id,
// Snapshot the entry photo so closing returns the user there even
// after they arrow-navigated through several neighbours.
previewOriginPhotoId: id,
// Adopt the caller-provided sequence when they pass one. Falls // Adopt the caller-provided sequence when they pass one. Falls
// back to whatever Timeline most recently published, which is // back to whatever Timeline most recently published, which is
// correct for paths like the global Space hotkey that don't have // correct for paths like the global Space hotkey that don't have
@@ -157,5 +177,62 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
: s.visiblePhotoIds, : s.visiblePhotoIds,
})), })),
closePreview: () => set({ viewMode: 'grid' }), closePreview: (restoreId) =>
set((s) => {
// Prefer the explicit restoreId from PreviewView (captured at
// mount time, immune to anything that might have cleared the
// in-store origin during the preview session). Fall back to the
// store-tracked origin and finally to the current active photo.
const finalId =
(restoreId ?? s.previewOriginPhotoId ?? s.activePhotoId) || null
return {
viewMode: 'grid',
activePhotoId: finalId,
rangeStartId: finalId,
selectedPhotos: finalId ? [finalId] : [],
previewOriginPhotoId: null,
}
}),
removePhotosFromTimeline: (ids) =>
set((state) => {
if (ids.length === 0) return state
const removed = new Set(ids)
const newSelected = state.selectedPhotos.filter((id) => !removed.has(id))
const newVisible = state.visiblePhotoIds.filter((id) => !removed.has(id))
// Pick a survivor for activePhotoId so arrow nav still has a
// landing spot. Walk forward from the old position first (the
// intuitive direction for "delete current, advance to next"),
// then fall back to walking backward if we were at the end.
let newActive = state.activePhotoId
if (newActive && removed.has(newActive)) {
const oldIdx = state.visiblePhotoIds.indexOf(newActive)
let found: string | null = null
if (oldIdx >= 0) {
for (let i = oldIdx + 1; i < state.visiblePhotoIds.length; i++) {
if (!removed.has(state.visiblePhotoIds[i])) {
found = state.visiblePhotoIds[i]
break
}
}
if (!found) {
for (let i = oldIdx - 1; i >= 0; i--) {
if (!removed.has(state.visiblePhotoIds[i])) {
found = state.visiblePhotoIds[i]
break
}
}
}
}
newActive = found ?? newVisible[0] ?? null
}
return {
selectedPhotos: newSelected,
visiblePhotoIds: newVisible,
activePhotoId: newActive,
rangeStartId: newActive,
}
}),
})) }))