feat: select and tag face from album images
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Vendored
+7
@@ -38,4 +38,11 @@ export interface IAlbumCreate {
|
||||
role: string;
|
||||
userId: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IAlbumPerson {
|
||||
id: string
|
||||
name: string
|
||||
numberOfPhotos: number
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user