From 7c6b75ebd981c745ec175d55a5554795dfddd12f Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 27 Jul 2025 12:47:53 +0530 Subject: [PATCH] feat: select and tag face from album images --- src/components/albums/info/AlbumPeople.tsx | 45 ++++--- .../albums/info/AlbumPersonNameBar.tsx | 114 ++++++++++++++++++ src/components/shared/FloatingBar.tsx | 4 +- src/components/ui/autocomplete.tsx | 4 +- src/pages/albums/[albumId].tsx | 35 ++++-- src/types/album.d.ts | 7 ++ 6 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 src/components/albums/info/AlbumPersonNameBar.tsx diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx index 379ef54..096e2ad 100644 --- a/src/components/albums/info/AlbumPeople.tsx +++ b/src/components/albums/info/AlbumPeople.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { IAlbum } from '@/types/album' +import React, { useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { IAlbum, IAlbumPerson } from '@/types/album' import { getAlbumPeople } from '@/handlers/api/album.handler' import Loader from '@/components/ui/loader' import LazyImage from '@/components/ui/lazy-image' @@ -17,17 +17,17 @@ import { Input } from '@/components/ui/input' interface AlbumPeopleProps { album: IAlbum - onSelect: (personId: string) => void + onSelect: (person: IAlbumPerson) => void readOnly?: boolean } -interface IAlbumPerson { - id: string - name: string - numberOfPhotos: number - +export interface IAlbumPeopleRef { + updatePerson: (person: IAlbumPerson) => void + mergePerson: (oldPerson: IAlbumPerson, newPerson: IAlbumPerson) => void } -export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeopleProps) { + + +const AlbumPeople = React.forwardRef(({ album, onSelect, readOnly }, ref) => { const { exImmichUrl } = useConfig() const router = useRouter() const { query, pathname } = router @@ -49,10 +49,10 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr const { knownPeople, unknownPeople } = useMemo(() => { return { - knownPeople: people.filter((person) => person.name), - unknownPeople: people.filter((person) => !person.name) + knownPeople: filteredPeople.filter((person) => person.name), + unknownPeople: filteredPeople.filter((person) => !person.name) } - }, [people]) + }, [filteredPeople]) const fetchPeople = async () => { return getAlbumPeople(album.id).then((people) => { @@ -114,6 +114,15 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr fetchPeople() }, []) + useImperativeHandle(ref, () => ({ + updatePerson: (person: IAlbumPerson) => { + setPeople((oldPeople) => oldPeople.map((p) => p.id === person.id ? person : p)) + }, + mergePerson: (oldPerson: IAlbumPerson, newPerson: IAlbumPerson) => { + setPeople((oldPeople) => oldPeople.map((p) => p.id === oldPerson.id ? newPerson : p)) + } + })) + if (loading) { return } @@ -134,7 +143,7 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr if (selectionMode) { handleBulkSelect(person.id) } else { - onSelect(person.id) + onSelect(person) } }} > @@ -206,7 +215,7 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr } }) }} /> - @@ -230,7 +239,7 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr )} {selectedPeople.length > 0 && (
-
@@ -257,4 +266,8 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr ) -} +}) + +AlbumPeople.displayName = "AlbumPeople" + +export default AlbumPeople diff --git a/src/components/albums/info/AlbumPersonNameBar.tsx b/src/components/albums/info/AlbumPersonNameBar.tsx new file mode 100644 index 0000000..5852ea1 --- /dev/null +++ b/src/components/albums/info/AlbumPersonNameBar.tsx @@ -0,0 +1,114 @@ +import FloatingBar from '@/components/shared/FloatingBar' +import { AlertDialog, IAlertDialogActions } from '@/components/ui/alert-dialog' +import { Autocomplete, AutocompleteOption } from '@/components/ui/autocomplete' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useToast } from '@/components/ui/use-toast' +import { mergePerson, searchPeople, updatePerson } from '@/handlers/api/people.handler' +import { IAlbumPerson } from '@/types/album' +import { useRef, useState } from 'react' +import { Toaster } from 'react-hot-toast' + +interface AlbumPersonNameBarProps { + selectedPerson: IAlbumPerson | null + faceId: string + onUpdate: (person: IAlbumPerson | null) => void + onMerge: (oldPerson: IAlbumPerson, newPerson: IAlbumPerson) => void +} + +export default function AlbumPersonNameBar({ selectedPerson, faceId, onUpdate, onMerge }: AlbumPersonNameBarProps) { + + const [newName, setNewName] = useState(selectedPerson?.name) + const [loading, setLoading] = useState(false) + const mergeDialogRef = useRef(null); + const selectedPersonRef = useRef(null); + + const { toast } = useToast() + + + const handleFormSubmit = () => { + if (!faceId) return + setLoading(true) + updatePerson(faceId, { + name: newName + }) + .then((person) => { + onUpdate(person) + }) + .catch((error) => { + toast({ + title: "Error updating person", + description: error.message, + variant: "destructive" + }) + }) + .finally(() => { + setLoading(false) + }) + } + + const handleMerge = async (option: AutocompleteOption) => { + if (!selectedPerson) return + return mergePerson(option.value, [selectedPerson.id]).then(() => { + onMerge(selectedPerson, { + ...selectedPerson, + id: option.value, + name: option.label, + }) + }) + .catch((error) => { + toast({ + title: "Error merging person", + description: error.message, + variant: "destructive" + }) + }) + .finally(() => { + setLoading(false) + }) + }; + + return ( + <> + +
+ searchPeople(query).then((people) => people.map((person: any) => ({ + label: person.name, value: person.id, + imageUrl: person.thumbnailPath + })))} + type="text" + defaultValue={newName} + placeholder="Enter name" + autoFocus + onChange={(e) => { + setNewName(e.target.value); + }} + value={newName || ""} + onOptionSelect={(value) => { + mergeDialogRef.current?.open(); + selectedPersonRef.current = value; + }} + createNewLabel="Create" + disabled={loading} + onCreateNew={() => { + console.log("create new") + handleFormSubmit(); + }} + /> +
+
+ { + if (selectedPersonRef.current) { + handleMerge(selectedPersonRef.current); + } + }} + /> + + ) +} \ No newline at end of file diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx index 06d1a4c..7793795 100644 --- a/src/components/shared/FloatingBar.tsx +++ b/src/components/shared/FloatingBar.tsx @@ -6,10 +6,8 @@ interface FloatingBarProps { export default function FloatingBar({ children }: FloatingBarProps) { return ( -
-
+
{children}
-
) } diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx index 3baa2f0..83eb5ec 100644 --- a/src/components/ui/autocomplete.tsx +++ b/src/components/ui/autocomplete.tsx @@ -20,6 +20,7 @@ export interface AutocompleteProps extends React.InputHTMLAttributes void createNewLabel?: string + position?: "top" | "bottom" } const Autocomplete = React.forwardRef( @@ -34,6 +35,7 @@ const Autocomplete = React.forwardRef( isLoading: externalLoading, onCreateNew, createNewLabel = "Create new", + position = "top", ...props }, forwardedRef) => { const [open, setOpen] = React.useState(false) @@ -193,7 +195,7 @@ const Autocomplete = React.forwardRef( }} /> {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && ( -
+
{isLoading ? (
Loading... diff --git a/src/pages/albums/[albumId].tsx b/src/pages/albums/[albumId].tsx index c4a3738..7012e8c 100644 --- a/src/pages/albums/[albumId].tsx +++ b/src/pages/albums/[albumId].tsx @@ -4,11 +4,11 @@ import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' import { getAlbumInfo } from '@/handlers/api/album.handler' import { deleteAssets, updateAssets } from '@/handlers/api/asset.handler' -import { IAlbum } from '@/types/album' +import { IAlbum, IAlbumPerson } from '@/types/album' import Link from 'next/link' -import React, { useEffect, useState, useMemo } from 'react' +import React, { useEffect, useState, useMemo, useRef } from 'react' import { useRouter } from 'next/router' -import AlbumPeople from '@/components/albums/info/AlbumPeople' +import AlbumPeople, { IAlbumPeopleRef } from '@/components/albums/info/AlbumPeople' import AlbumImages from '@/components/albums/info/AlbumImages' import { Camera, ExternalLink, Users } from 'lucide-react' import { humanizeNumber } from '@/helpers/string.helper' @@ -17,6 +17,8 @@ import FloatingBar from '@/components/shared/FloatingBar' import { Button } from '@/components/ui/button' import { AlertDialog } from '@/components/ui/alert-dialog' import AssetOffsetDialog from '@/components/assets/assets-options/AssetOffsetDialog' +import { Input } from '@/components/ui/input' +import AlbumPersonNameBar from '@/components/albums/info/AlbumPersonNameBar' export default function AlbumListPage() { const { exImmichUrl } = useConfig() @@ -26,6 +28,8 @@ export default function AlbumListPage() { const [albumInfo, setAlbumInfo] = useState() const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') + const [selectedPerson, setSelectedPerson] = useState(null) + const albumPeopleRef = useRef(null) // PhotoSelection context state const [contextState, setContextState] = useState({ @@ -73,8 +77,9 @@ export default function AlbumListPage() { }); }, [albumId]); - const handleSelectPerson = (personId: string) => { - if (faceId === personId) { + const handleSelectPerson = (person: IAlbumPerson) => { + setSelectedPerson(person) + if (faceId === person.id) { router.push({ pathname, query: { @@ -86,12 +91,24 @@ export default function AlbumListPage() { pathname, query: { albumId, - faceId: personId + faceId: person.id } }) } } + const handleUpdatePerson = (person: IAlbumPerson | null) => { + setSelectedPerson(person) + if (person) { + albumPeopleRef.current?.updatePerson(person) + } + } + + const handleMergePerson = (oldPerson: IAlbumPerson, newPerson: IAlbumPerson) => { + setSelectedPerson(newPerson) + albumPeopleRef.current?.mergePerson(oldPerson, newPerson) + } + const handleDelete = () => { return deleteAssets(contextState.selectedIds).then(() => { const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id)); @@ -119,7 +136,7 @@ export default function AlbumListPage() {
{albumInfo && ( <> - + )} @@ -210,6 +227,10 @@ export default function AlbumListPage() {
} + + {!selectedAssets.length && faceId && !selectedPerson?.name && ( + + )} ) diff --git a/src/types/album.d.ts b/src/types/album.d.ts index 0e3f281..5ca8201 100644 --- a/src/types/album.d.ts +++ b/src/types/album.d.ts @@ -38,4 +38,11 @@ export interface IAlbumCreate { role: string; userId: string; }[]; +} + +export interface IAlbumPerson { + id: string + name: string + numberOfPhotos: number + } \ No newline at end of file