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:
170
frontend/src/components/rated/RatedView.tsx
Normal file
170
frontend/src/components/rated/RatedView.tsx
Normal 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 1–5 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user