feat: refactor grouping views into card-grid browse pattern
Replace Timeline-based grouped views (tags, colors, rated) with dedicated card-grid components that drill into Timeline detail views on click/Enter. Adds shared useCardGridNav hook for arrow-key navigation across all four card grids (tags, colors, rated, people). - TagsView, ColorsView, RatedView: card grid → inline Timeline detail - PeopleView: migrated to same pattern (Timeline replaces custom grid) - Tags endpoint: fall back to first associated photo for representative - Filter store: add ratingMax for exact rating filtering in RatedView - Timeline: remove tag/rating/color grouping; skip date headers when groupBy != 'date' so detail views render flat grids - SettingsDialog: bump z-index above Leaflet map layers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
frontend/src/hooks/useCardGridNav.ts
Normal file
115
frontend/src/hooks/useCardGridNav.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
|
||||
/**
|
||||
* Keyboard navigation for card grids (tags, colors, ratings, people).
|
||||
*
|
||||
* Arrow keys move the active index through the grid (wrapping at row
|
||||
* boundaries based on the actual CSS column count), Enter opens the
|
||||
* selected card, and Escape / Backspace exits the detail view.
|
||||
*
|
||||
* In detail mode, Escape only exits back to the card grid when the
|
||||
* preview is closed and no photos are selected — otherwise it defers
|
||||
* to Timeline's own Escape handler (clear selection / close preview).
|
||||
*
|
||||
* The grid container ref is used to measure the rendered column count
|
||||
* so up/down navigation stays column-aligned.
|
||||
*/
|
||||
export function useCardGridNav<T>(opts: {
|
||||
items: T[]
|
||||
/** True when the detail view is showing (disables grid nav, enables Esc) */
|
||||
inDetail: boolean
|
||||
onEnter: (item: T, index: number) => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
const { items, inDetail, onEnter, onExit } = opts
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||
|
||||
// Clamp active index when the item list shrinks
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && activeIndex >= items.length) {
|
||||
setActiveIndex(items.length - 1)
|
||||
}
|
||||
}, [items.length, activeIndex])
|
||||
|
||||
// Measure column count from the grid container
|
||||
const getColumns = useCallback(() => {
|
||||
const el = gridRef.current
|
||||
if (!el) return 1
|
||||
return getComputedStyle(el).gridTemplateColumns.split(' ').length
|
||||
}, [])
|
||||
|
||||
// Scroll the active card into view
|
||||
const scrollIntoView = useCallback((index: number) => {
|
||||
const el = gridRef.current
|
||||
if (!el) return
|
||||
const card = el.children[index] as HTMLElement | undefined
|
||||
card?.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
|
||||
|
||||
// Detail view: Escape or Backspace exits back to card grid, but
|
||||
// only when the preview is closed and no photos are selected —
|
||||
// otherwise defer to Timeline's own Escape handler.
|
||||
if (inDetail) {
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
} else if (e.key === 'Escape' && viewMode === 'grid' && selectedPhotos.length === 0) {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Card grid navigation
|
||||
const cols = getColumns()
|
||||
const count = items.length
|
||||
let next = activeIndex
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
next = Math.min(activeIndex + 1, count - 1)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
next = Math.max(activeIndex - 1, 0)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
next = Math.min(activeIndex + cols, count - 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
next = Math.max(activeIndex - cols, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (items[activeIndex]) onEnter(items[activeIndex], activeIndex)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (next !== activeIndex) {
|
||||
setActiveIndex(next)
|
||||
scrollIntoView(next)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [items, activeIndex, inDetail, onEnter, onExit, getColumns, scrollIntoView, viewMode, selectedPhotos])
|
||||
|
||||
return { activeIndex, setActiveIndex, gridRef }
|
||||
}
|
||||
@@ -60,6 +60,12 @@ function parseUrl(): HydratePayload {
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n
|
||||
}
|
||||
|
||||
const rx = sp.get('rating_max')
|
||||
if (rx) {
|
||||
const n = parseInt(rx, 10)
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMax = n
|
||||
}
|
||||
|
||||
const cl = sp.get('color_label')
|
||||
if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) {
|
||||
out.colorLabel = cl as ColorLabel
|
||||
@@ -110,6 +116,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
|
||||
if (f.dateTo) sp.set('date_to', f.dateTo)
|
||||
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
|
||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||
if (f.ratingMax > 0) sp.set('rating_max', String(f.ratingMax))
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||
|
||||
@@ -32,6 +32,7 @@ export function usePhotosQuery() {
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const ratingMax = useFilterStore((s) => s.ratingMax)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
@@ -50,6 +51,7 @@ export function usePhotosQuery() {
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
ratingMax,
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
@@ -60,7 +62,7 @@ export function usePhotosQuery() {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
Reference in New Issue
Block a user