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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user