fix: preview navigation walks the timeline's visible order

Previously the preview view walked the raw API photos array for arrow
navigation and the filmstrip. In tag-grouped mode (and any future
layout where the visible grid order diverges from the API sort) that
diverged from the order the user actually saw — they'd hit ← / → and
land on a photo that wasn't adjacent in the grid.

Fix: Timeline publishes its flat visible-order id sequence into the
photo store as visiblePhotoIds whenever its layout items change
(including duplicates from tag buckets, which is what the user wants
in tag mode — landing on a photo's second appearance in the next
bucket is the right behavior). PreviewView resolves that sequence
back to Photo objects via the rawPhotos map and uses the result for
both arrow nav and the filmstrip. Falls back to the raw photos list
when the sequence isn't populated yet.

Also clean up the lingering hardcoded http://localhost:8001 in
usePhotosQuery — switched to the shared axios instance with the
relative /api/v1 baseURL so the hook works cross-machine through the
nginx / vite proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 21:13:08 +02:00
parent 7a0f738aa8
commit a56062d353
5 changed files with 53 additions and 7 deletions

View File

@@ -150,8 +150,8 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Metadata</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
@@ -13,6 +13,7 @@ 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)
@@ -20,7 +21,23 @@ export function PreviewView() {
// 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: photos = [] } = usePhotosQuery()
const { data: rawPhotos = [] } = usePhotosQuery()
// Walk the timeline's visible-order sequence (published by Timeline
// into the photo store), which respects tag-grouping and any other
// grid-layout rearrangement. Falls back to the raw photos list when
// the sequence isn't populated yet — relevant on a fresh page load
// where the user opened preview before the timeline mounted.
const photos: Photo[] = useMemo(() => {
if (visiblePhotoIds.length === 0) return rawPhotos
const byId = new Map(rawPhotos.map((p) => [p.id, p]))
const out: Photo[] = []
for (const id of visiblePhotoIds) {
const p = byId.get(id)
if (p) out.push(p)
}
return out
}, [visiblePhotoIds, rawPhotos])
const currentIndex = activePhotoId
? photos.findIndex((p) => p.id === activePhotoId)

View File

@@ -172,6 +172,7 @@ export function Timeline() {
togglePhotoSelection,
clearSelection,
openPreview,
setVisiblePhotoIds,
} = usePhotoStore()
const sortBy = useFilterStore((s) => s.sortBy)
@@ -294,6 +295,21 @@ export function Timeline() {
[items]
)
// Publish the flat visible-order id sequence to the photo store so
// PreviewView arrow nav (and the filmstrip) walks the same order the
// user sees in the grid. Includes duplicates from tag grouping —
// landing on the same photo's "second" appearance in the next tag
// bucket is the right behavior in tag mode.
useEffect(() => {
const ids: string[] = []
for (const row of photoRows) {
for (const cell of row.cells) {
ids.push(cell.photo.id)
}
}
setVisiblePhotoIds(ids)
}, [photoRows, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
// can repeat a photo across groups. Returns null when there's no

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api'
import type { Photo } from '../types/photo'
/**
@@ -50,8 +50,11 @@ export function usePhotosQuery() {
return useQuery({
queryKey: ['photos', filterParams],
queryFn: async () => {
const response = await axios.get<{ photos: Photo[]; total: number }>(
'http://localhost:8001/api/v1/photos',
// 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.
const response = await api.get<{ photos: Photo[]; total: number }>(
'/photos',
{
params: {
page: 1,

View File

@@ -10,6 +10,12 @@ interface PhotoStore {
lastSelectedIndex: number | null
rangeStartIndex: number | null
viewMode: ViewMode
/** Flat sequence of photo ids in the order they currently appear in
* the timeline grid (including duplicates from tag-grouping). The
* preview view walks this sequence so arrow nav matches the order
* the user actually sees. Owned by the Timeline component, which
* rewrites it whenever its layout items change. */
visiblePhotoIds: string[]
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
@@ -19,6 +25,7 @@ interface PhotoStore {
clearSelection: () => void
setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
setVisiblePhotoIds: (ids: string[]) => void
openPreview: (id: string) => void
closePreview: () => void
}
@@ -30,6 +37,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
lastSelectedIndex: null,
rangeStartIndex: null,
viewMode: 'grid',
visiblePhotoIds: [],
setPhotos: (photos) => set({ photos }),
@@ -73,6 +81,8 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
setViewMode: (mode) => set({ viewMode: mode }),
setVisiblePhotoIds: (visiblePhotoIds) => set({ visiblePhotoIds }),
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
closePreview: () => set({ viewMode: 'grid' }),