fix: People view shows person's photos inline instead of navigating away

Clicking a person card now opens a detail sub-view within the People
section showing their photo grid. Back arrow returns to the card grid.
Photos are clickable to open the preview. Rename is available in both
the card grid and the detail header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:51:22 +02:00
parent 229611b4c3
commit aba061dd43

View File

@@ -1,23 +1,28 @@
import { useState } from 'react' import { useState } from 'react'
import { Users, Pencil, Check, X, Loader2 } from 'lucide-react' import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import { tags as tagsApi, photos as photosApi, type Tag } from '../../services/api' import {
import { useFilterStore } from '../../store/filterStore' tags as tagsApi,
photos as photosApi,
search as searchApi,
type Tag,
} from '../../services/api'
import { usePhotoStore } from '../../store/photoStore'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
/** /**
* People view — grid of face cluster cards. Each card shows the * People view — two states:
* representative photo thumbnail, the cluster name, and a photo count. * 1. Grid of face cluster cards (default)
* Clicking a card navigates to the all-photos section filtered to that * 2. Detail view showing a person's photos when a card is clicked
* person's tag. The name is editable inline.
*/ */
export function PeopleView() { export function PeopleView() {
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster') const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const openPreview = usePhotoStore((s) => s.openPreview)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
@@ -27,6 +32,10 @@ export function PeopleView() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] }) queryClient.invalidateQueries({ queryKey: ['tags'] })
setEditingId(null) 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() })
}
toast.success('Renamed') toast.success('Renamed')
}, },
onError: (e: any) => onError: (e: any) =>
@@ -43,11 +52,24 @@ export function PeopleView() {
renameMutation.mutate({ id: editingId, name: editName.trim() }) renameMutation.mutate({ id: editingId, name: editName.trim() })
} }
const handleCardClick = (tag: Tag) => { // ── Detail view: a person's photos ─────────────────────────────────
if (editingId === tag.id) return if (selectedPerson) {
navigateToSection('all-photos', { tagIds: [tag.id] }) return (
<PersonDetail
person={selectedPerson}
onBack={() => setSelectedPerson(null)}
onRename={() => startEditing(selectedPerson)}
editingId={editingId}
editName={editName}
setEditName={setEditName}
submitRename={submitRename}
cancelEdit={() => setEditingId(null)}
openPreview={openPreview}
/>
)
} }
// ── Card grid ──────────────────────────────────────────────────────
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center text-text-muted"> <div className="flex h-full items-center justify-center text-text-muted">
@@ -87,9 +109,10 @@ export function PeopleView() {
'group cursor-pointer overflow-hidden rounded-lg border border-border bg-surface transition-all hover:border-primary/50 hover:shadow-md', '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' editingId === tag.id && 'border-primary ring-1 ring-primary/30'
)} )}
onClick={() => handleCardClick(tag)} onClick={() => {
if (editingId !== tag.id) setSelectedPerson(tag)
}}
> >
{/* Thumbnail — representative photo or placeholder */}
<div className="relative aspect-square overflow-hidden bg-surface-2"> <div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? ( {tag.representative_photo_id ? (
<img <img
@@ -103,12 +126,10 @@ export function PeopleView() {
</div> </div>
)} )}
{/* Photo count badge */}
<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"> <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} {tag.photo_count}
</span> </span>
{/* Edit button — visible on hover */}
<button <button
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100" className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
@@ -121,7 +142,6 @@ export function PeopleView() {
</button> </button>
</div> </div>
{/* Name — inline editable */}
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">
{editingId === tag.id ? ( {editingId === tag.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
@@ -135,16 +155,10 @@ export function PeopleView() {
}} }}
className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none" className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/> />
<button <button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
onClick={submitRename}
className="rounded p-0.5 text-green-500 hover:bg-green-500/10"
>
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>
<button <button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
onClick={() => setEditingId(null)}
className="rounded p-0.5 text-text-muted hover:bg-surface-2"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</div> </div>
@@ -158,3 +172,131 @@ export function PeopleView() {
</div> </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>
)
}