feat: refactor grouping views into card-grid browse pattern

Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).

- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
  groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:48:50 +02:00
parent fa9b21856f
commit 4bc6dc1dc8
13 changed files with 737 additions and 317 deletions

View File

@@ -0,0 +1,183 @@
import { useState, useMemo, useCallback } from 'react'
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { COLOR_LABEL_OPTIONS, type ColorLabel } from '../../constants/colorLabels'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import type { Photo } from '../../types/photo'
interface ColorGroup {
label: string
value: ColorLabel | null
className: string
count: number
representative: Photo | null
}
/**
* Colors view — two states:
* 1. Grid of color label cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a color's photos in the full Timeline — Esc to go back
*/
export function ColorsView() {
const { data: allPhotos = [], isLoading } = usePhotosQuery()
const setColorLabel = useFilterStore((s) => s.setColorLabel)
const [selectedGroup, setSelectedGroup] = useState<ColorGroup | null>(null)
const groups = useMemo(() => {
const buckets = new Map<string, Photo[]>()
const uncolored: Photo[] = []
for (const photo of allPhotos) {
if (photo.color_label) {
const arr = buckets.get(photo.color_label) ?? []
arr.push(photo)
buckets.set(photo.color_label, arr)
} else {
uncolored.push(photo)
}
}
const result: ColorGroup[] = []
for (const { value, className } of COLOR_LABEL_OPTIONS) {
const photos = buckets.get(value) ?? []
if (photos.length === 0) continue
result.push({
label: value.charAt(0).toUpperCase() + value.slice(1),
value,
className,
count: photos.length,
representative: photos[0],
})
}
if (uncolored.length > 0) {
result.push({
label: 'Uncolored',
value: null,
className: 'bg-neutral-400',
count: uncolored.length,
representative: uncolored[0],
})
}
return result
}, [allPhotos])
const enterDetail = useCallback(
(group: ColorGroup) => {
setColorLabel((group.value ?? 'none') as ColorLabel)
setSelectedGroup(group)
},
[setColorLabel]
)
const exitDetail = useCallback(() => {
setColorLabel(null)
setSelectedGroup(null)
}, [setColorLabel])
const { activeIndex, gridRef } = useCardGridNav({
items: groups,
inDetail: selectedGroup !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedGroup) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<span className={`inline-block h-3 w-3 rounded-full ${selectedGroup.className}`} />
<h2 className="text-sm font-semibold text-text">{selectedGroup.label}</h2>
</div>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading colors...
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Palette className="h-12 w-12 opacity-40" />
<p className="text-sm">No color labels assigned yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Color labels will appear here once you assign them to photos.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Palette className="h-4 w-4" />
<span className="text-sm font-medium">
{groups.length} {groups.length === 1 ? 'color' : 'colors'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{groups.map((group, i) => (
<div
key={group.label}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(group)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{group.representative ? (
<img
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
alt={group.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Palette className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{group.count}
</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${group.className}`} />
<p className="truncate text-xs font-medium text-text">{group.label}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -195,7 +195,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 z-[2000]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}

View File

@@ -258,7 +258,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
navigateToSection('tags', { groupBy: 'tag' })
break
case 'people':
navigateToSection('people', {})
navigateToSection('people', { groupBy: 'tag' })
break
case 'colors':
navigateToSection('colors', { groupBy: 'color' })

View File

@@ -1,26 +1,27 @@
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
tags as tagsApi,
photos as photosApi,
search as searchApi,
type Tag,
} from '../../services/api'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import { toast } from '../ToastContainer'
/**
* People view — two states:
* 1. Grid of face cluster cards (default)
* 2. Detail view showing a person's photos when a card is clicked
* 1. Grid of face cluster cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a person's photos in the full Timeline — Esc to go back
*/
export function PeopleView() {
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
const queryClient = useQueryClient()
const openPreview = usePhotoStore((s) => s.openPreview)
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
@@ -32,7 +33,6 @@ export function PeopleView() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] })
setEditingId(null)
// Update the selected person's name if we're renaming the active one
if (selectedPerson && editingId === selectedPerson.id) {
setSelectedPerson({ ...selectedPerson, name: editName.trim() })
}
@@ -52,20 +52,76 @@ export function PeopleView() {
renameMutation.mutate({ id: editingId, name: editName.trim() })
}
const enterDetail = useCallback(
(person: Tag) => {
setTagIds([person.id])
setSelectedPerson(person)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedPerson(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: clusters,
inDetail: selectedPerson !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
// ── Detail view: a person's photos ─────────────────────────────────
if (selectedPerson) {
const isEditing = editingId === selectedPerson.id
return (
<PersonDetail
person={selectedPerson}
onBack={() => setSelectedPerson(null)}
onRename={() => startEditing(selectedPerson)}
editingId={editingId}
editName={editName}
setEditName={setEditName}
submitRename={submitRename}
cancelEdit={() => setEditingId(null)}
openPreview={openPreview}
/>
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to people"
>
<ArrowLeft className="h-4 w-4" />
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
<Check className="h-4 w-4" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-1 text-text-muted hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text">{selectedPerson.name}</h2>
<button
onClick={() => startEditing(selectedPerson)}
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
@@ -101,16 +157,23 @@ export function PeopleView() {
</span>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{clusters.map((tag) => (
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{clusters.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border border-border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
editingId === tag.id && 'border-primary ring-1 ring-primary/30'
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: editingId === tag.id
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => {
if (editingId !== tag.id) setSelectedPerson(tag)
if (editingId !== tag.id) enterDetail(tag)
}}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
@@ -172,131 +235,3 @@ export function PeopleView() {
</div>
)
}
// ── Person detail sub-view ───────────────────────────────────────────
interface PersonDetailProps {
person: Tag
onBack: () => void
onRename: () => void
editingId: string | null
editName: string
setEditName: (v: string) => void
submitRename: () => void
cancelEdit: () => void
openPreview: (photoId: string, photoIds: string[]) => void
}
function PersonDetail({
person,
onBack,
onRename,
editingId,
editName,
setEditName,
submitRename,
cancelEdit,
openPreview,
}: PersonDetailProps) {
const { data, isLoading } = useQuery({
queryKey: ['person-photos', person.id],
queryFn: async () => {
const resp = await searchApi.query({
filters: { tag_ids: [person.id] },
limit: 500,
})
return resp.results
},
})
const photos = data ?? []
const isEditing = editingId === person.id
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={onBack}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to people"
>
<ArrowLeft className="h-4 w-4" />
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') cancelEdit()
}}
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
<Check className="h-4 w-4" />
</button>
<button onClick={cancelEdit} className="rounded p-1 text-text-muted hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text">{person.name}</h2>
<button
onClick={onRename}
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-xs text-text-muted">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'}
</span>
</div>
{/* Photo grid */}
<div className="flex-1 overflow-auto p-3">
{isLoading ? (
<div className="flex h-32 items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading photos...
</div>
) : photos.length === 0 ? (
<p className="py-8 text-center text-sm text-text-muted">No photos found</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
{photos.map((photo) => (
<div
key={photo.id}
className="group cursor-pointer overflow-hidden rounded-md border border-border bg-surface-2 transition-all hover:border-primary/50 hover:shadow-md"
onClick={() =>
openPreview(
photo.id,
photos.map((p) => p.id)
)
}
>
<div className="aspect-square overflow-hidden">
<img
src={photosApi.getThumbnailUrl(photo.id, 'small')}
alt={photo.filename}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import type { Photo } from '../../types/photo'
interface RatingGroup {
rating: number
label: string
count: number
representative: Photo | null
}
/**
* Rated view — two states:
* 1. Grid of rating-level cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a rating level's photos in the full Timeline — Esc to go back
*/
export function RatedView() {
const { data: allPhotos = [], isLoading } = usePhotosQuery()
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setRatingMax = useFilterStore((s) => s.setRatingMax)
const [selectedGroup, setSelectedGroup] = useState<RatingGroup | null>(null)
const groups = useMemo(() => {
const buckets = new Map<number, Photo[]>()
for (const photo of allPhotos) {
if (photo.rating > 0) {
const arr = buckets.get(photo.rating) ?? []
arr.push(photo)
buckets.set(photo.rating, arr)
}
}
// Highest rating first
const result: RatingGroup[] = []
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r) ?? []
if (photos.length === 0) continue
result.push({
rating: r,
label: '★'.repeat(r),
count: photos.length,
representative: photos[0],
})
}
return result
}, [allPhotos])
const enterDetail = useCallback(
(group: RatingGroup) => {
setRatingMin(group.rating)
setRatingMax(group.rating)
setSelectedGroup(group)
},
[setRatingMin, setRatingMax]
)
const exitDetail = useCallback(() => {
// Restore the section preset: ratingMin=1 (all rated), no max
setRatingMin(1)
setRatingMax(0)
setSelectedGroup(null)
}, [setRatingMin, setRatingMax])
const { activeIndex, gridRef } = useCardGridNav({
items: groups,
inDetail: selectedGroup !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedGroup) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to ratings"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading ratings...
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Star className="h-12 w-12 opacity-40" />
<p className="text-sm">No rated photos yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Rate photos with 15 stars and they will appear here grouped by
rating.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Star className="h-4 w-4" />
<span className="text-sm font-medium">
{groups.length} rating {groups.length === 1 ? 'level' : 'levels'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{groups.map((group, i) => (
<div
key={group.rating}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(group)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{group.representative ? (
<img
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
alt={group.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Star className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{group.count}
</span>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
/**
* Tags view — two states:
* 1. Grid of tag cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a tag's photos in the full Timeline — Esc to go back
*/
export function TagsView() {
const { data: allTags = [], isLoading } = useTagsQuery()
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
// Exclude face_cluster tags (those live in PeopleView)
const tags = useMemo(
() => allTags.filter((t) => t.kind !== 'face_cluster'),
[allTags]
)
const enterDetail = useCallback(
(tag: Tag) => {
setTagIds([tag.id])
setSelectedTag(tag)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedTag(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: tags,
inDetail: selectedTag !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedTag) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to tags"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading tags...
</div>
)
}
if (tags.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<TagIcon className="h-12 w-12 opacity-40" />
<p className="text-sm">No tags yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Tags will appear here once photos are tagged either manually or by
the auto-tagger.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<TagIcon className="h-4 w-4" />
<span className="text-sm font-medium">
{tags.length} {tags.length === 1 ? 'tag' : 'tags'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{tags.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(tag)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? (
<img
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
alt={tag.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<TagIcon className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{tag.photo_count}
</span>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -6,7 +6,6 @@ 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.
@@ -27,22 +26,19 @@ type TimelineItem =
/**
* Build the flat header|row item array the virtualizer renders.
*
* 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).
* Two modes:
* - sortBy is a date field: month buckets.
* - otherwise: one un-headered stream.
*
* Tag, rating, and color grouping now live in their own dedicated views
* (TagsView, RatedView, ColorsView) instead of being handled here.
*/
function buildItems(
photos: Photo[],
columns: number,
rowHeight: number,
sortBy: string,
groupBy: 'date' | 'tag' | 'rating' | 'color'
groupBy: string,
): TimelineItem[] {
if (photos.length === 0) return []
@@ -61,155 +57,13 @@ function buildItems(
}
}
// ── Tag grouping ──────────────────────────────────────────────────────
if (groupBy === 'tag') {
// Bucket by tag name. A photo with multiple tags lands in multiple
// buckets. Photos with no tags go into "Untagged".
const tagBuckets = new Map<string, PhotoCell[]>()
const untagged: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const tags = photo.tags ?? []
if (tags.length === 0) {
untagged.push(cell)
} else {
for (const t of tags) {
const arr = tagBuckets.get(t.name) ?? []
arr.push(cell)
tagBuckets.set(t.name, arr)
}
}
})
// Sort tag groups alphabetically; Untagged goes at the end.
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
a.localeCompare(b)
)
let bucketIndex = 0
for (const name of sortedTagNames) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::${name}`,
label: name,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
bucketIndex++
}
if (untagged.length > 0) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::__untagged`,
label: 'Untagged',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
}
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<number, PhotoCell[]>()
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
}
// ── 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 only applies when groupBy is explicitly 'date' and
// the sort field is a date column. Other sections (tags, colors,
// rated, people) reuse Timeline for their detail views and should
// render a flat grid without month headers.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort) {
if (!isDateSort || groupBy !== 'date') {
// No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
photo,
@@ -344,8 +198,7 @@ export function Timeline() {
const activeHeapName = activeHeap?.name ?? null
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers
// appear when groupBy === 'tag' (overrides date grouping).
// photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo(
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
[photos, columns, cellSize, sortBy, groupBy]