From f01b5ed77e9d89c3b0a4e80eca51cb8b610f08ea Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Apr 2026 00:37:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20snappier=20timeline=20=E2=80=94=20insta?= =?UTF-8?q?nt=20discard,=20progressive=20load,=20restore=20preview=20origi?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/App.tsx | 6 +- .../components/discard/DiscardActionBar.tsx | 14 ++- .../src/components/layout/LeftSidebar.tsx | 9 ++ .../src/components/layout/RightSidebar.tsx | 8 ++ .../src/components/preview/PreviewView.tsx | 53 ++++++++- frontend/src/components/timeline/Timeline.tsx | 43 ++++++++ frontend/src/hooks/useKeyboardShortcuts.ts | 12 +++ frontend/src/hooks/usePhotosQuery.ts | 102 +++++++++++++----- frontend/src/store/photoStore.ts | 81 +++++++++++++- 9 files changed, 290 insertions(+), 38 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e70c0f5..e592951 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,11 @@ function App() { // Right sidebar stays open by default and shows whatever's selected // (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 (
diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index 7b6b631..92db19f 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -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 diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 16d2609..d47cbbd 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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'}`, diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index a485341..454e675 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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, }) diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index 10ab453..fb0b3dd 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -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(null) const previouslyFocusedRef = useRef(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( + 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" >
No photo to display