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 { Users, Pencil, Check, X, Loader2 } from 'lucide-react'
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
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 { tags as tagsApi, photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import {
tags as tagsApi,
photos as photosApi,
search as searchApi,
type Tag,
} from '../../services/api'
import { usePhotoStore } from '../../store/photoStore'
import { toast } from '../ToastContainer'
/**
* People view — grid of face cluster cards. Each card shows the
* representative photo thumbnail, the cluster name, and a photo count.
* Clicking a card navigates to the all-photos section filtered to that
* person's tag. The name is editable inline.
* People view — two states:
* 1. Grid of face cluster cards (default)
* 2. Detail view showing a person's photos when a card is clicked
*/
export function PeopleView() {
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const queryClient = useQueryClient()
const openPreview = usePhotoStore((s) => s.openPreview)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
@@ -27,6 +32,10 @@ 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() })
}
toast.success('Renamed')
},
onError: (e: any) =>
@@ -43,11 +52,24 @@ export function PeopleView() {
renameMutation.mutate({ id: editingId, name: editName.trim() })
}
const handleCardClick = (tag: Tag) => {
if (editingId === tag.id) return
navigateToSection('all-photos', { tagIds: [tag.id] })
// ── Detail view: a person's photos ─────────────────────────────────
if (selectedPerson) {
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) {
return (
<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',
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">
{tag.representative_photo_id ? (
<img
@@ -103,12 +126,10 @@ export function PeopleView() {
</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">
{tag.photo_count}
</span>
{/* Edit button — visible on hover */}
<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"
onClick={(e) => {
@@ -121,7 +142,6 @@ export function PeopleView() {
</button>
</div>
{/* Name — inline editable */}
<div className="px-2 py-1.5">
{editingId === tag.id ? (
<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"
/>
<button
onClick={submitRename}
className="rounded p-0.5 text-green-500 hover:bg-green-500/10"
>
<button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
<Check className="h-3 w-3" />
</button>
<button
onClick={() => setEditingId(null)}
className="rounded p-0.5 text-text-muted hover:bg-surface-2"
>
<button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
<X className="h-3 w-3" />
</button>
</div>
@@ -158,3 +172,131 @@ 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>
)
}