diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 3f08a02..c27d3fb 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -10,6 +10,7 @@ import { HardDrive, Copy, Tag as TagIcon, + Palette, Layers2, MoreHorizontal, Pencil, @@ -244,6 +245,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { case 'tags': navigateToSection('tags', { groupBy: 'tag' }) break + case 'colors': + navigateToSection('colors', { groupBy: 'color' }) + break default: if (id.startsWith('folder-')) { const folderId = id.slice('folder-'.length) @@ -344,6 +348,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { { id: 'all-photos', label: 'All Photos', icon: , count: stats?.all_photos ?? 0 }, { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, + { id: 'colors', label: 'Colors', icon: }, { id: 'duplicates', label: 'Duplicates', icon: , count: stats?.duplicates ?? 0 }, { id: 'discarded', label: 'Discarded', icon: , count: stats?.discarded ?? 0 }, ], diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 7b289ca..6067bda 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { photos as photosApi } from '../../services/api' import type { Photo } from '../../types/photo' import { usePhotoStore } from '../../store/photoStore' +import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' /** Custom MIME used by HeapsPanel to recognise our drag payload. */ export const PHOTO_DRAG_MIME = 'application/x-mulita-photos' @@ -28,12 +29,21 @@ export const THUMB_BADGE_BASE = export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0' /** Standard icon size for any lucide glyph inside a badge. */ export const THUMB_BADGE_ICON = 'h-3 w-3' -export const THUMB_BADGE_PRIMARY = - 'bg-primary shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]' +/** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow. + * Shared by every "user-affirmed" variant so any swatch (primary, pick, + * color label) reads as part of the same badge family. */ +const THUMB_BADGE_FRAME = + 'shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]' +export const THUMB_BADGE_PRIMARY = `bg-primary ${THUMB_BADGE_FRAME}` export const THUMB_BADGE_NEUTRAL = 'bg-black/75 backdrop-blur-sm shadow-[0_0_0_1px_rgba(255,255,255,0.12),inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.55)]' -export const THUMB_BADGE_PICK = - 'bg-pick shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]' +export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}` + +/** Tailwind bg-class for each color label, looked up at render time so the + * classnames are statically present in the source for the JIT to scan. */ +const COLOR_LABEL_BG: Record = Object.fromEntries( + COLOR_LABEL_OPTIONS.map((o) => [o.value, o.className]) +) // Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so // the first hit on a freshly-scanned library returns 404 "not ready" until @@ -254,18 +264,30 @@ export function PhotoThumbnail({ )} - {/* BL — rating */} - {photo.rating > 0 && ( -
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