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

@@ -6,7 +6,6 @@ import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers.
@@ -27,22 +26,19 @@ type TimelineItem =
/**
* Build the flat header|row item array the virtualizer renders.
*
* Five modes:
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
* for photos with no tags). A photo with N tags appears in N buckets.
* - groupBy='rating': one bucket per star rating 5..1 (plus "Unrated"
* for rating 0). Each photo lands in exactly one bucket.
* - groupBy='color': one bucket per color label, in canonical order
* (plus an "Uncolored" bucket for photos with no label).
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
* Two modes:
* - sortBy is a date field: month buckets.
* - otherwise: one un-headered stream.
*
* Tag, rating, and color grouping now live in their own dedicated views
* (TagsView, RatedView, ColorsView) instead of being handled here.
*/
function buildItems(
photos: Photo[],
columns: number,
rowHeight: number,
sortBy: string,
groupBy: 'date' | 'tag' | 'rating' | 'color'
groupBy: string,
): TimelineItem[] {
if (photos.length === 0) return []
@@ -61,155 +57,13 @@ function buildItems(
}
}
// ── Tag grouping ──────────────────────────────────────────────────────
if (groupBy === 'tag') {
// Bucket by tag name. A photo with multiple tags lands in multiple
// buckets. Photos with no tags go into "Untagged".
const tagBuckets = new Map<string, PhotoCell[]>()
const untagged: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const tags = photo.tags ?? []
if (tags.length === 0) {
untagged.push(cell)
} else {
for (const t of tags) {
const arr = tagBuckets.get(t.name) ?? []
arr.push(cell)
tagBuckets.set(t.name, arr)
}
}
})
// Sort tag groups alphabetically; Untagged goes at the end.
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
a.localeCompare(b)
)
let bucketIndex = 0
for (const name of sortedTagNames) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::${name}`,
label: name,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
bucketIndex++
}
if (untagged.length > 0) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::__untagged`,
label: 'Untagged',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
}
return items
}
// ── Rating grouping ───────────────────────────────────────────────────
if (groupBy === 'rating') {
// Bucket by star rating. Each photo lands in exactly one bucket;
// rating 0 goes into "Unrated".
const ratingBuckets = new Map<number, PhotoCell[]>()
const unrated: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
if (photo.rating > 0) {
const arr = ratingBuckets.get(photo.rating) ?? []
arr.push(cell)
ratingBuckets.set(photo.rating, arr)
} else {
unrated.push(cell)
}
})
// Highest rating first; Unrated goes at the end.
const sortedRatings = Array.from(ratingBuckets.keys()).sort((a, b) => b - a)
let bucketIndex = 0
for (const rating of sortedRatings) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::${rating}`,
label: '★'.repeat(rating),
height: HEADER_HEIGHT,
})
pushRowsForGroup(
`rating::${bucketIndex}::${rating}`,
ratingBuckets.get(rating)!
)
bucketIndex++
}
if (unrated.length > 0) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::__unrated`,
label: 'Unrated',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`rating::${bucketIndex}::unrated`, unrated)
}
return items
}
// ── Color label grouping ──────────────────────────────────────────────
if (groupBy === 'color') {
// Bucket by color_label. Each photo lands in exactly one bucket;
// photos with no label go into "Uncolored".
const colorBuckets = new Map<string, PhotoCell[]>()
const uncolored: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const label = photo.color_label
if (label) {
const arr = colorBuckets.get(label) ?? []
arr.push(cell)
colorBuckets.set(label, arr)
} else {
uncolored.push(cell)
}
})
// Walk the canonical color order so headers always read R-O-Y-G-B-P,
// matching every other color UI in the app. Skip empty buckets and
// ignore any unexpected label values that aren't in the canonical
// list (they'd be invalid backend state).
let bucketIndex = 0
for (const { value } of COLOR_LABEL_OPTIONS) {
const cells = colorBuckets.get(value)
if (!cells || cells.length === 0) continue
const label = value.charAt(0).toUpperCase() + value.slice(1)
items.push({
type: 'header',
key: `color::${bucketIndex}::${value}`,
label,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`color::${bucketIndex}::${value}`, cells)
bucketIndex++
}
if (uncolored.length > 0) {
items.push({
type: 'header',
key: `color::${bucketIndex}::__uncolored`,
label: 'Uncolored',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`color::${bucketIndex}::uncolored`, uncolored)
}
return items
}
// ── Date grouping (existing) ──────────────────────────────────────────
// Date grouping only applies when groupBy is explicitly 'date' and
// the sort field is a date column. Other sections (tags, colors,
// rated, people) reuse Timeline for their detail views and should
// render a flat grid without month headers.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort) {
if (!isDateSort || groupBy !== 'date') {
// No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
photo,
@@ -344,8 +198,7 @@ export function Timeline() {
const activeHeapName = activeHeap?.name ?? null
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers
// appear when groupBy === 'tag' (overrides date grouping).
// photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo(
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
[photos, columns, cellSize, sortBy, groupBy]