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