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:
@@ -10,6 +10,7 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Copy,
|
Copy,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
|
Palette,
|
||||||
Layers2,
|
Layers2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -244,6 +245,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
case 'tags':
|
case 'tags':
|
||||||
navigateToSection('tags', { groupBy: 'tag' })
|
navigateToSection('tags', { groupBy: 'tag' })
|
||||||
break
|
break
|
||||||
|
case 'colors':
|
||||||
|
navigateToSection('colors', { groupBy: 'color' })
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
const folderId = id.slice('folder-'.length)
|
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: '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: '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: '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: '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 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
|
|
||||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
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'
|
export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0'
|
||||||
/** Standard icon size for any lucide glyph inside a badge. */
|
/** Standard icon size for any lucide glyph inside a badge. */
|
||||||
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
||||||
export const THUMB_BADGE_PRIMARY =
|
/** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow.
|
||||||
'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)]'
|
* 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 =
|
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)]'
|
'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 =
|
export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}`
|
||||||
'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)]'
|
|
||||||
|
/** 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
|
// 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
|
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||||
@@ -254,18 +264,30 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BL — rating */}
|
{/* BL — color label + rating. Color comes first (left of rating)
|
||||||
{photo.rating > 0 && (
|
* so the swatch reads as a "category dot" prefixing the stars. */}
|
||||||
<div
|
{(photo.color_label || photo.rating > 0) && (
|
||||||
className={clsx(
|
<div className="absolute bottom-1 left-1 flex items-center gap-1">
|
||||||
'absolute bottom-1 left-1 gap-0.5',
|
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
|
||||||
THUMB_BADGE_BASE,
|
<div
|
||||||
THUMB_BADGE_PRIMARY
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useFilterStore } from '../../store/filterStore'
|
|||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers.
|
||||||
@@ -26,11 +27,13 @@ type TimelineItem =
|
|||||||
/**
|
/**
|
||||||
* Build the flat header|row item array the virtualizer renders.
|
* 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
|
* - 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.
|
* 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"
|
* - groupBy='rating': one bucket per star rating 5..1 (plus "Unrated"
|
||||||
* for rating 0). Each photo lands in exactly one bucket.
|
* 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).
|
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
|
||||||
* - otherwise: one un-headered stream.
|
* - otherwise: one un-headered stream.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +42,7 @@ function buildItems(
|
|||||||
columns: number,
|
columns: number,
|
||||||
rowHeight: number,
|
rowHeight: number,
|
||||||
sortBy: string,
|
sortBy: string,
|
||||||
groupBy: 'date' | 'tag' | 'rating'
|
groupBy: 'date' | 'tag' | 'rating' | 'color'
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
if (photos.length === 0) return []
|
if (photos.length === 0) return []
|
||||||
|
|
||||||
@@ -154,6 +157,55 @@ function buildItems(
|
|||||||
return items
|
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 (existing) ──────────────────────────────────────────
|
||||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type SortField =
|
|||||||
| 'file_size'
|
| 'file_size'
|
||||||
| 'rating'
|
| 'rating'
|
||||||
export type SortOrder = 'asc' | 'desc'
|
export type SortOrder = 'asc' | 'desc'
|
||||||
export type GroupBy = 'date' | 'tag' | 'rating'
|
export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Photo {
|
|||||||
height: number | null
|
height: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
|
color_label?: string | null
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
|
|||||||
Reference in New Issue
Block a user