feat: select and tag face from album images

This commit is contained in:
Varun Raj
2025-07-27 12:47:53 +05:30
parent b9d7e54c8a
commit 7c6b75ebd9
6 changed files with 182 additions and 27 deletions
+29 -16
View File
@@ -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<IAlbumPeopleRef, AlbumPeopleProps>(({ 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 <Loader />
}
@@ -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
}
})
}} />
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
<Button disabled={hidingPerson} variant={"outline"} className="!py-0.5 !px-2 text-xs h-7 flex-1" onClick={() => handleHidePerson(selectedPerson.id)}>
Hide
</Button>
</div>
@@ -230,7 +239,7 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
)}
{selectedPeople.length > 0 && (
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
<Button variant="default" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
Hide {selectedPeople.length} people
</Button>
</div>
@@ -257,4 +266,8 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
</div>
</div>
)
}
})
AlbumPeople.displayName = "AlbumPeople"
export default AlbumPeople
@@ -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<IAlertDialogActions>(null);
const selectedPersonRef = useRef<AutocompleteOption | null>(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 (
<>
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<Autocomplete
position="bottom"
loadOptions={(query: string) => 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();
}}
/>
</div>
</FloatingBar>
<AlertDialog
ref={mergeDialogRef}
title="Merge Person"
description="Are you sure you want to merge this person with the selected person?"
onConfirm={() => {
if (selectedPersonRef.current) {
handleMerge(selectedPersonRef.current);
}
}}
/>
</>
)
}
+1 -3
View File
@@ -6,10 +6,8 @@ interface FloatingBarProps {
export default function FloatingBar({ children }: FloatingBarProps) {
return (
<div className='absolute bottom-10 left-0 right-0'>
<div className='max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border'>
<div className='absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border'>
{children}
</div>
</div>
)
}
+3 -1
View File
@@ -20,6 +20,7 @@ export interface AutocompleteProps extends React.InputHTMLAttributes<HTMLInputEl
isLoading?: boolean
onCreateNew?: (inputValue: string) => void
createNewLabel?: string
position?: "top" | "bottom"
}
const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
@@ -34,6 +35,7 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
isLoading: externalLoading,
onCreateNew,
createNewLabel = "Create new",
position = "top",
...props
}, forwardedRef) => {
const [open, setOpen] = React.useState(false)
@@ -193,7 +195,7 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
}}
/>
{open && (filteredOptions.length > 0 || isLoading || showCreateNew) && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md">
<div className={cn("absolute left-0 right-0 z-50 mt-1 max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md", position === "top" ? "top-full" : "bottom-full")}>
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
Loading...
+28 -7
View File
@@ -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<IAlbum>()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [selectedPerson, setSelectedPerson] = useState<IAlbumPerson | null>(null)
const albumPeopleRef = useRef<IAlbumPeopleRef>(null)
// PhotoSelection context state
const [contextState, setContextState] = useState<IPhotoSelectionContext>({
@@ -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() {
<div className="flex divide-y">
{albumInfo && (
<>
<AlbumPeople album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} />
<AlbumPeople ref={albumPeopleRef} album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} />
<AlbumImages album={albumInfo} key={albumInfo.id} />
</>
)}
@@ -210,6 +227,10 @@ export default function AlbumListPage() {
</div>
</FloatingBar>
}
{!selectedAssets.length && faceId && !selectedPerson?.name && (
<AlbumPersonNameBar selectedPerson={selectedPerson} faceId={faceId} onUpdate={handleUpdatePerson} onMerge={handleMergePerson} />
)}
</PhotoSelectionContext.Provider>
</PageLayout>
)
+7
View File
@@ -38,4 +38,11 @@ export interface IAlbumCreate {
role: string;
userId: string;
}[];
}
export interface IAlbumPerson {
id: string
name: string
numberOfPhotos: number
}