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:
2026-04-10 15:48:50 +02:00
parent fa9b21856f
commit 4bc6dc1dc8
13 changed files with 737 additions and 317 deletions

View 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 }
}

View File

@@ -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)

View File

@@ -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()