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:
@@ -48,12 +48,13 @@ async def list_tags(
|
||||
select(
|
||||
photo_tags.c.tag_id,
|
||||
func.count(photo_tags.c.photo_id).label("photo_count"),
|
||||
func.min(photo_tags.c.photo_id).label("first_photo_id"),
|
||||
)
|
||||
.group_by(photo_tags.c.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count)
|
||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
)
|
||||
if kind:
|
||||
@@ -70,10 +71,10 @@ async def list_tags(
|
||||
"color": tag.color,
|
||||
"kind": tag.kind,
|
||||
"source": tag.source,
|
||||
"representative_photo_id": tag.representative_photo_id,
|
||||
"representative_photo_id": tag.representative_photo_id or first_photo_id,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for tag, count in rows
|
||||
for tag, count, first_photo_id in rows
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Timeline } from './components/timeline/Timeline'
|
||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||
import { MapView } from './components/map/MapView'
|
||||
import { PeopleView } from './components/people/PeopleView'
|
||||
import { TagsView } from './components/tags/TagsView'
|
||||
import { ColorsView } from './components/colors/ColorsView'
|
||||
import { RatedView } from './components/rated/RatedView'
|
||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||
import { RightSidebar } from './components/layout/RightSidebar'
|
||||
import { TopBar } from './components/layout/TopBar'
|
||||
@@ -89,6 +92,12 @@ function App() {
|
||||
<DuplicatesView />
|
||||
) : currentSection === 'people' ? (
|
||||
<PeopleView />
|
||||
) : currentSection === 'tags' ? (
|
||||
<TagsView />
|
||||
) : currentSection === 'colors' ? (
|
||||
<ColorsView />
|
||||
) : currentSection === 'rated' ? (
|
||||
<RatedView />
|
||||
) : (
|
||||
<Timeline />
|
||||
)}
|
||||
|
||||
183
frontend/src/components/colors/ColorsView.tsx
Normal file
183
frontend/src/components/colors/ColorsView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
170
frontend/src/components/rated/RatedView.tsx
Normal file
170
frontend/src/components/rated/RatedView.tsx
Normal 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 1–5 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>
|
||||
)
|
||||
}
|
||||
138
frontend/src/components/tags/TagsView.tsx
Normal file
138
frontend/src/components/tags/TagsView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
115
frontend/src/hooks/useCardGridNav.ts
Normal file
115
frontend/src/hooks/useCardGridNav.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
|
||||
/**
|
||||
* Keyboard navigation for card grids (tags, colors, ratings, people).
|
||||
*
|
||||
* Arrow keys move the active index through the grid (wrapping at row
|
||||
* boundaries based on the actual CSS column count), Enter opens the
|
||||
* selected card, and Escape / Backspace exits the detail view.
|
||||
*
|
||||
* In detail mode, Escape only exits back to the card grid when the
|
||||
* preview is closed and no photos are selected — otherwise it defers
|
||||
* to Timeline's own Escape handler (clear selection / close preview).
|
||||
*
|
||||
* The grid container ref is used to measure the rendered column count
|
||||
* so up/down navigation stays column-aligned.
|
||||
*/
|
||||
export function useCardGridNav<T>(opts: {
|
||||
items: T[]
|
||||
/** True when the detail view is showing (disables grid nav, enables Esc) */
|
||||
inDetail: boolean
|
||||
onEnter: (item: T, index: number) => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
const { items, inDetail, onEnter, onExit } = opts
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||
|
||||
// Clamp active index when the item list shrinks
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && activeIndex >= items.length) {
|
||||
setActiveIndex(items.length - 1)
|
||||
}
|
||||
}, [items.length, activeIndex])
|
||||
|
||||
// Measure column count from the grid container
|
||||
const getColumns = useCallback(() => {
|
||||
const el = gridRef.current
|
||||
if (!el) return 1
|
||||
return getComputedStyle(el).gridTemplateColumns.split(' ').length
|
||||
}, [])
|
||||
|
||||
// Scroll the active card into view
|
||||
const scrollIntoView = useCallback((index: number) => {
|
||||
const el = gridRef.current
|
||||
if (!el) return
|
||||
const card = el.children[index] as HTMLElement | undefined
|
||||
card?.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
|
||||
|
||||
// Detail view: Escape or Backspace exits back to card grid, but
|
||||
// only when the preview is closed and no photos are selected —
|
||||
// otherwise defer to Timeline's own Escape handler.
|
||||
if (inDetail) {
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
} else if (e.key === 'Escape' && viewMode === 'grid' && selectedPhotos.length === 0) {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Card grid navigation
|
||||
const cols = getColumns()
|
||||
const count = items.length
|
||||
let next = activeIndex
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
next = Math.min(activeIndex + 1, count - 1)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
next = Math.max(activeIndex - 1, 0)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
next = Math.min(activeIndex + cols, count - 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
next = Math.max(activeIndex - cols, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (items[activeIndex]) onEnter(items[activeIndex], activeIndex)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (next !== activeIndex) {
|
||||
setActiveIndex(next)
|
||||
scrollIntoView(next)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [items, activeIndex, inDetail, onEnter, onExit, getColumns, scrollIntoView, viewMode, selectedPhotos])
|
||||
|
||||
return { activeIndex, setActiveIndex, gridRef }
|
||||
}
|
||||
@@ -60,6 +60,12 @@ function parseUrl(): HydratePayload {
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n
|
||||
}
|
||||
|
||||
const rx = sp.get('rating_max')
|
||||
if (rx) {
|
||||
const n = parseInt(rx, 10)
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMax = n
|
||||
}
|
||||
|
||||
const cl = sp.get('color_label')
|
||||
if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) {
|
||||
out.colorLabel = cl as ColorLabel
|
||||
@@ -110,6 +116,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
|
||||
if (f.dateTo) sp.set('date_to', f.dateTo)
|
||||
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
|
||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||
if (f.ratingMax > 0) sp.set('rating_max', String(f.ratingMax))
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||
|
||||
@@ -32,6 +32,7 @@ export function usePhotosQuery() {
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const ratingMax = useFilterStore((s) => s.ratingMax)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
@@ -50,6 +51,7 @@ export function usePhotosQuery() {
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
ratingMax,
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
@@ -60,7 +62,7 @@ export function usePhotosQuery() {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface FilterState {
|
||||
dateTo: string | null
|
||||
mediaTypes: MediaType[]
|
||||
ratingMin: number // 0-5; 0 means no filter
|
||||
ratingMax: number // 0-5; 0 means no filter
|
||||
colorLabel: ColorLabel | null
|
||||
flag: FlagFilter
|
||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||
@@ -59,6 +60,7 @@ interface FilterStore extends FilterState {
|
||||
setDateTo: (date: string | null) => void
|
||||
toggleMediaType: (t: MediaType) => void
|
||||
setRatingMin: (rating: number) => void
|
||||
setRatingMax: (rating: number) => void
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
@@ -93,6 +95,7 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
dateTo: null,
|
||||
mediaTypes: [],
|
||||
ratingMin: 0,
|
||||
ratingMax: 0,
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
@@ -114,6 +117,7 @@ function snapshotFilters(s: FilterState): FilterState {
|
||||
dateTo: s.dateTo,
|
||||
mediaTypes: [...s.mediaTypes],
|
||||
ratingMin: s.ratingMin,
|
||||
ratingMax: s.ratingMax,
|
||||
colorLabel: s.colorLabel,
|
||||
flag: s.flag,
|
||||
heapId: s.heapId,
|
||||
@@ -142,6 +146,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
: [...s.mediaTypes, t],
|
||||
})),
|
||||
setRatingMin: (ratingMin) => set({ ratingMin }),
|
||||
setRatingMax: (ratingMax) => set({ ratingMax }),
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
setHeapId: (heapId) => set({ heapId }),
|
||||
@@ -205,6 +210,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
||||
if (f.dateTo) params.date_to = f.dateTo
|
||||
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
|
||||
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
||||
if (f.ratingMax > 0) params.rating_max = f.ratingMax
|
||||
if (f.colorLabel) params.color_label = f.colorLabel
|
||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
@@ -224,6 +230,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
||||
f.dateTo !== null ||
|
||||
f.mediaTypes.length > 0 ||
|
||||
f.ratingMin > 0 ||
|
||||
f.ratingMax > 0 ||
|
||||
f.colorLabel !== null ||
|
||||
f.flag !== 'any' ||
|
||||
f.heapId !== null ||
|
||||
|
||||
Reference in New Issue
Block a user