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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user