feat: colors view and color label thumbnail ornament

Adds a Colors entry under Views that buckets photos in canonical
R-O-Y-G-B-P order (plus an Uncolored tail), mirroring the rated/tags
grouping pattern. Surfaces the color label on each thumbnail as a
small swatch chip preceding the rating badge in the BL corner so
labels are visible everywhere, not just inside the new view. Also
types color_label on the Photo interface — the backend already
returned it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:41:06 +02:00
parent 870e7dd3d3
commit c973ca0443
5 changed files with 98 additions and 18 deletions

View File

@@ -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: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" /> },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
],

View File

@@ -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<string, string> = 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({
</div>
)}
{/* BL — rating */}
{photo.rating > 0 && (
<div
className={clsx(
'absolute bottom-1 left-1 gap-0.5',
THUMB_BADGE_BASE,
THUMB_BADGE_PRIMARY
{/* BL — color label + rating. Color comes first (left of rating)
* so the swatch reads as a "category dot" prefixing the stars. */}
{(photo.color_label || photo.rating > 0) && (
<div className="absolute bottom-1 left-1 flex items-center gap-1">
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
<div
className={clsx(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
COLOR_LABEL_BG[photo.color_label],
THUMB_BADGE_FRAME
)}
title={`Color label: ${photo.color_label}`}
/>
)}
{photo.rating > 0 && (
<div
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
>
{Array.from({ length: photo.rating }).map((_, i) => (
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
))}
</div>
)}
>
{Array.from({ length: photo.rating }).map((_, i) => (
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
))}
</div>
)}

View File

@@ -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<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) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'

View File

@@ -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

View File

@@ -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