From 30cbcb8cd1a9762c75dfb18d56ace6fb4cf5712d Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Apr 2026 20:30:44 +0200 Subject: [PATCH] feat: group rated view by star rating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the tags view: rated section now buckets photos 5★→1★ with an "Unrated" tail group, instead of a flat ratingMin=1 stream. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/LeftSidebar.tsx | 2 +- frontend/src/components/timeline/Timeline.tsx | 53 ++++++++++++++++++- frontend/src/store/filterStore.ts | 2 +- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 3d79927..3f08a02 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -233,7 +233,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { navigateToSection('all-photos', {}) break case 'rated': - navigateToSection('rated', { ratingMin: 1 }) + navigateToSection('rated', { ratingMin: 1, groupBy: 'rating' }) break case 'discarded': navigateToSection('discarded', { flag: 'discarded' }) diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 7b09bf3..6691d0e 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -26,9 +26,11 @@ type TimelineItem = /** * Build the flat header|row item array the virtualizer renders. * - * Three modes: + * Four 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='date' AND sortBy is a date field: month buckets (existing). * - otherwise: one un-headered stream. */ @@ -37,7 +39,7 @@ function buildItems( columns: number, rowHeight: number, sortBy: string, - groupBy: 'date' | 'tag' + groupBy: 'date' | 'tag' | 'rating' ): TimelineItem[] { if (photos.length === 0) return [] @@ -105,6 +107,53 @@ function buildItems( 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() + 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 + } + // ── 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 cb0d57f..246e842 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' +export type GroupBy = 'date' | 'tag' | 'rating' export interface FilterState { q: string