diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e592951..c27c838 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Timeline } from './components/timeline/Timeline' import { DuplicatesView } from './components/duplicates/DuplicatesView' import { MapView } from './components/map/MapView' +import { PeopleView } from './components/people/PeopleView' import { LeftSidebar } from './components/layout/LeftSidebar' import { RightSidebar } from './components/layout/RightSidebar' import { TopBar } from './components/layout/TopBar' @@ -86,6 +87,8 @@ function App() { ) : currentSection === 'duplicates' ? ( + ) : currentSection === 'people' ? ( + ) : ( )} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 346fa7b..05ad1ef 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -258,7 +258,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { navigateToSection('tags', { groupBy: 'tag' }) break case 'people': - navigateToSection('people', { groupBy: 'tag' }) + navigateToSection('people', {}) break case 'colors': navigateToSection('colors', { groupBy: 'color' }) diff --git a/frontend/src/components/people/PeopleView.tsx b/frontend/src/components/people/PeopleView.tsx new file mode 100644 index 0000000..42e2432 --- /dev/null +++ b/frontend/src/components/people/PeopleView.tsx @@ -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(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 ( +
+ + Loading people... +
+ ) + } + + if (clusters.length === 0) { + return ( +
+ +

No people identified yet

+

+ Face detection runs automatically when photos are scanned. + People will appear here once faces are found and clustered. +

+
+ ) + } + + return ( +
+
+ + + {clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified + +
+ +
+ {clusters.map((tag) => ( +
handleCardClick(tag)} + > + {/* Thumbnail — representative photo or placeholder */} +
+ {tag.representative_photo_id ? ( + {tag.name} + ) : ( +
+ +
+ )} + + {/* Photo count badge */} + + {tag.photo_count} + + + {/* Edit button — visible on hover */} + +
+ + {/* Name — inline editable */} +
+ {editingId === tag.id ? ( +
e.stopPropagation()}> + 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" + /> + + +
+ ) : ( +

{tag.name}

+ )} +
+
+ ))} +
+
+ ) +}