Files
mule-image/frontend/src/components/rated/RatedView.tsx
dtoro 4bc6dc1dc8 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>
2026-04-10 15:48:50 +02:00

171 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}