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