diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 4cf93b5..ead1d9d 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -144,7 +144,7 @@ export function LeftSidebar() {
}
}
}
-
+
// Fetch the recursive folder tree (one root per active source root).
const { data: folderTree = [] } = useFolderTreeQuery()
@@ -178,11 +178,11 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
-
+
const handleScanAll = () => {
scanLibraryMutation.mutate()
}
-
+
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
@@ -192,7 +192,7 @@ export function LeftSidebar() {
}
setExpandedItems(newExpanded)
}
-
+
// Recursively map a backend FolderTreeNode into our generic TreeItem.
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
id: `folder-${node.id}`,
@@ -228,7 +228,7 @@ export function LeftSidebar() {
children: folderTree.map(folderNodeToTreeItem),
},
]
-
+
// Derive whether a tree item is currently the "active" filter target.
// Folder rows are selected when the filter store's folderId matches; the
// library "All Photos" virtual node is selected when no folder/heap filter
@@ -299,10 +299,10 @@ export function LeftSidebar() {
onDoubleClick={
item.id.startsWith('folder-')
? (e) => {
- e.stopPropagation()
- setRenamingId(item.id)
- setRenameDraft(item.label)
- }
+ e.stopPropagation()
+ setRenamingId(item.id)
+ setRenameDraft(item.label)
+ }
: undefined
}
onDragOver={acceptsDrop ? (e) => {
@@ -345,14 +345,14 @@ export function LeftSidebar() {
) : (
{/* Sidebar Header */}
-
Library
+ Views
-
+
{/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))}
-
+
{/* Bottom Actions */}
{folderTree.length > 0 && (
diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx
index 1e400e1..c160db7 100644
--- a/frontend/src/components/timeline/Timeline.tsx
+++ b/frontend/src/components/timeline/Timeline.tsx
@@ -167,6 +167,7 @@ export function Timeline() {
const {
selectedPhotos,
+ activePhotoId,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
@@ -282,50 +283,102 @@ export function Timeline() {
return () => window.removeEventListener('resize', measureWidth)
}, [])
- // Handle keyboard shortcuts for photo navigation. Operates on the flat
- // photos array, so it ignores grouping.
+ // Photo rows in visual order — drops the header items so navigation
+ // walks the grid as the user sees it. Each row has cells of length
+ // [1..columns], the last row of a group can be short, and a single
+ // photo with multiple tags will appear in multiple rows.
+ const photoRows = useMemo(
+ () => items.filter((it): it is Extract => it.type === 'row'),
+ [items]
+ )
+
+ // 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
+ // active photo or it isn't currently rendered.
+ const findActiveCell = (): { row: number; col: number } | null => {
+ if (!activePhotoId) return null
+ for (let r = 0; r < photoRows.length; r++) {
+ const row = photoRows[r]
+ const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
+ if (c >= 0) return { row: r, col: c }
+ }
+ return null
+ }
+
+ // Handle keyboard shortcuts for photo navigation. Operates on the
+ // grouped grid the user sees, so a half-full last row of a group
+ // doesn't make ArrowDown skip into the wrong place.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if (photos.length === 0) return
+ if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
- const currentIndex = lastSelectedIndex ?? -1
+ const move = (dr: number, dc: number) => {
+ const current = findActiveCell() ?? { row: 0, col: -1 }
+ let nextRow = current.row
+ let nextCol = current.col + dc
+
+ if (dc !== 0) {
+ // Wrap left/right across row boundaries.
+ while (nextCol < 0 && nextRow > 0) {
+ nextRow -= 1
+ nextCol = photoRows[nextRow].cells.length - 1
+ }
+ while (
+ nextRow < photoRows.length &&
+ nextCol >= photoRows[nextRow].cells.length
+ ) {
+ if (nextRow === photoRows.length - 1) {
+ nextCol = photoRows[nextRow].cells.length - 1
+ break
+ }
+ nextRow += 1
+ nextCol = 0
+ }
+ if (nextCol < 0) nextCol = 0
+ }
+
+ if (dr !== 0) {
+ nextRow += dr
+ if (nextRow < 0) nextRow = 0
+ if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
+ // Clamp the column to the destination row's actual width so
+ // moving down into a half-full row lands on its last cell
+ // instead of nothing.
+ const rowLen = photoRows[nextRow].cells.length
+ if (nextCol >= rowLen) nextCol = rowLen - 1
+ if (nextCol < 0) nextCol = 0
+ }
+
+ const dest = photoRows[nextRow]?.cells[nextCol]
+ if (!dest) return
+ if (e.shiftKey) {
+ selectRange(dest.globalIndex)
+ } else {
+ selectPhoto(dest.photo.id, dest.globalIndex)
+ }
+ }
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
- if (currentIndex > columns - 1) {
- const newIndex = currentIndex - columns
- if (e.shiftKey) selectRange(newIndex)
- else selectPhoto(photos[newIndex].id, newIndex)
- }
+ move(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
- if (currentIndex < photos.length - columns) {
- const newIndex = Math.min(currentIndex + columns, photos.length - 1)
- if (e.shiftKey) selectRange(newIndex)
- else selectPhoto(photos[newIndex].id, newIndex)
- }
+ move(1, 0)
break
case 'ArrowLeft':
e.preventDefault()
- if (currentIndex > 0) {
- const newIndex = currentIndex - 1
- if (e.shiftKey) selectRange(newIndex)
- else selectPhoto(photos[newIndex].id, newIndex)
- }
+ move(0, -1)
break
case 'ArrowRight':
e.preventDefault()
- if (currentIndex < photos.length - 1) {
- const newIndex = currentIndex + 1
- if (e.shiftKey) selectRange(newIndex)
- else selectPhoto(photos[newIndex].id, newIndex)
- }
+ move(0, 1)
break
case 'a':
if (e.ctrlKey || e.metaKey) {
@@ -347,7 +400,7 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [photos, selectedPhotos, lastSelectedIndex, columns])
+ }, [photoRows, photos, selectedPhotos, activePhotoId])
if (isLoading) {
return (