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,170 @@
import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import type { Photo } from '../../types/photo'
interface RatingGroup {
rating: number
label: string
count: number
representative: Photo | null
}
/**
* Rated view — two states:
* 1. Grid of rating-level cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a rating level's photos in the full Timeline — Esc to go back
*/
export function RatedView() {
const { data: allPhotos = [], isLoading } = usePhotosQuery()
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setRatingMax = useFilterStore((s) => s.setRatingMax)
const [selectedGroup, setSelectedGroup] = useState<RatingGroup | null>(null)
const groups = useMemo(() => {
const buckets = new Map<number, Photo[]>()
for (const photo of allPhotos) {
if (photo.rating > 0) {
const arr = buckets.get(photo.rating) ?? []
arr.push(photo)
buckets.set(photo.rating, arr)
}
}
// Highest rating first
const result: RatingGroup[] = []
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r) ?? []
if (photos.length === 0) continue
result.push({
rating: r,
label: '★'.repeat(r),
count: photos.length,
representative: photos[0],
})
}
return result
}, [allPhotos])
const enterDetail = useCallback(
(group: RatingGroup) => {
setRatingMin(group.rating)
setRatingMax(group.rating)
setSelectedGroup(group)
},
[setRatingMin, setRatingMax]
)
const exitDetail = useCallback(() => {
// Restore the section preset: ratingMin=1 (all rated), no max
setRatingMin(1)
setRatingMax(0)
setSelectedGroup(null)
}, [setRatingMin, setRatingMax])
const { activeIndex, gridRef } = useCardGridNav({
items: groups,
inDetail: selectedGroup !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedGroup) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to ratings"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading ratings...
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Star className="h-12 w-12 opacity-40" />
<p className="text-sm">No rated photos yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Rate photos with 15 stars and they will appear here grouped by
rating.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Star className="h-4 w-4" />
<span className="text-sm font-medium">
{groups.length} rating {groups.length === 1 ? 'level' : 'levels'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{groups.map((group, i) => (
<div
key={group.rating}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(group)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{group.representative ? (
<img
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
alt={group.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Star className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{group.count}
</span>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
</div>
</div>
))}
</div>
</div>
)
}