feat: add People view with person cards and click-to-filter
Replace the tag-grouping people section with a dedicated PeopleView: - Grid of face cluster cards showing representative photo thumbnail, person name, and photo count - Click a card → navigates to all-photos filtered by that person's tag - Inline rename via pencil icon on hover - Empty state when no faces have been clustered yet Wired into App.tsx as a section-level route alongside Map and Duplicates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||||
import { MapView } from './components/map/MapView'
|
import { MapView } from './components/map/MapView'
|
||||||
|
import { PeopleView } from './components/people/PeopleView'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
import { TopBar } from './components/layout/TopBar'
|
import { TopBar } from './components/layout/TopBar'
|
||||||
@@ -86,6 +87,8 @@ function App() {
|
|||||||
<MapView />
|
<MapView />
|
||||||
) : currentSection === 'duplicates' ? (
|
) : currentSection === 'duplicates' ? (
|
||||||
<DuplicatesView />
|
<DuplicatesView />
|
||||||
|
) : currentSection === 'people' ? (
|
||||||
|
<PeopleView />
|
||||||
) : (
|
) : (
|
||||||
<Timeline />
|
<Timeline />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
navigateToSection('tags', { groupBy: 'tag' })
|
navigateToSection('tags', { groupBy: 'tag' })
|
||||||
break
|
break
|
||||||
case 'people':
|
case 'people':
|
||||||
navigateToSection('people', { groupBy: 'tag' })
|
navigateToSection('people', {})
|
||||||
break
|
break
|
||||||
case 'colors':
|
case 'colors':
|
||||||
navigateToSection('colors', { groupBy: 'color' })
|
navigateToSection('colors', { groupBy: 'color' })
|
||||||
|
|||||||
160
frontend/src/components/people/PeopleView.tsx
Normal file
160
frontend/src/components/people/PeopleView.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Users, Pencil, Check, X, Loader2 } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { 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 { 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.
|
||||||
|
*/
|
||||||
|
export function PeopleView() {
|
||||||
|
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
|
||||||
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState('')
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||||
|
tagsApi.update(id, { name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
setEditingId(null)
|
||||||
|
toast.success('Renamed')
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Rename failed', e?.response?.data?.detail || e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const startEditing = (tag: Tag) => {
|
||||||
|
setEditingId(tag.id)
|
||||||
|
setEditName(tag.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRename = () => {
|
||||||
|
if (!editingId || !editName.trim()) return
|
||||||
|
renameMutation.mutate({ id: editingId, name: editName.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (tag: Tag) => {
|
||||||
|
if (editingId === tag.id) return
|
||||||
|
navigateToSection('all-photos', { tagIds: [tag.id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 people...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clusters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||||
|
<Users className="h-12 w-12 opacity-40" />
|
||||||
|
<p className="text-sm">No people identified yet</p>
|
||||||
|
<p className="max-w-xs text-center text-xs opacity-70">
|
||||||
|
Face detection runs automatically when photos are scanned.
|
||||||
|
People will appear here once faces are found and clustered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-text-muted">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||||
|
{clusters.map((tag) => (
|
||||||
|
<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'
|
||||||
|
)}
|
||||||
|
onClick={() => handleCardClick(tag)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail — representative photo or placeholder */}
|
||||||
|
<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">
|
||||||
|
<Users className="h-10 w-10 text-text-muted/30" />
|
||||||
|
</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) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startEditing(tag)
|
||||||
|
}}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</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()}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') submitRename()
|
||||||
|
if (e.key === 'Escape') setEditingId(null)
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user