0) && (
+
+ {photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
+
+ )}
+ {photo.rating > 0 && (
+
+ {Array.from({ length: photo.rating }).map((_, i) => (
+
+ ))}
+
)}
- >
- {Array.from({ length: photo.rating }).map((_, i) => (
-
- ))}
)}
diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx
index 6691d0e..409478c 100644
--- a/frontend/src/components/timeline/Timeline.tsx
+++ b/frontend/src/components/timeline/Timeline.tsx
@@ -6,6 +6,7 @@ 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.
@@ -26,11 +27,13 @@ type TimelineItem =
/**
* Build the flat header|row item array the virtualizer renders.
*
- * Four modes:
+ * 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).
* - otherwise: one un-headered stream.
*/
@@ -39,7 +42,7 @@ function buildItems(
columns: number,
rowHeight: number,
sortBy: string,
- groupBy: 'date' | 'tag' | 'rating'
+ groupBy: 'date' | 'tag' | 'rating' | 'color'
): TimelineItem[] {
if (photos.length === 0) return []
@@ -154,6 +157,55 @@ function buildItems(
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
()
+ 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) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts
index 246e842..43ffd6b 100644
--- a/frontend/src/store/filterStore.ts
+++ b/frontend/src/store/filterStore.ts
@@ -11,7 +11,7 @@ export type SortField =
| 'file_size'
| 'rating'
export type SortOrder = 'asc' | 'desc'
-export type GroupBy = 'date' | 'tag' | 'rating'
+export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
export interface FilterState {
q: string
diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts
index d23e46f..d258f63 100644
--- a/frontend/src/types/photo.ts
+++ b/frontend/src/types/photo.ts
@@ -13,6 +13,7 @@ export interface Photo {
height: number | null
taken_at: string | null
rating: number
+ color_label?: string | null
is_discarded: boolean
is_duplicate: boolean
file_hash: string