diff --git a/.env.sample b/.env.sample index f3c875d..8893da8 100644 --- a/.env.sample +++ b/.env.sample @@ -1,7 +1,7 @@ IMMICH_URL="" # Immich URL IMMICH_API_KEY="" # Immich API Key DB_USERNAME="" # Postgress Database Username -DB_PASSWORD = "" # Postgres Database Password -DB_HOST = "" # Postgres Host (IP address or hostname of the database) -DB_PORT = "" # Postgres Port number (Default: 5432) -DB_DATABASE_NAME = "" # Name of the database \ No newline at end of file +DB_PASSWORD="" # Postgres Database Password +DB_HOST="" # Postgres Host (IP address or hostname of the database) +DB_PORT="" # Postgres Port number (Default: 5432) +DB_DATABASE_NAME="" # Name of the database \ No newline at end of file diff --git a/src/components/people/PersonMergeDropdown.tsx b/src/components/people/PersonMergeDropdown.tsx index 6273233..710d8e2 100644 --- a/src/components/people/PersonMergeDropdown.tsx +++ b/src/components/people/PersonMergeDropdown.tsx @@ -1,44 +1,75 @@ "use client"; import { Button } from "@/components/ui/button"; + import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; -import { Merge } from "lucide-react"; -import { mergePerson, searchPeople } from "@/handlers/api/people.handler"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { SearchSlash, X } from "lucide-react"; +import { + listSimilarFaces, + mergePerson, + searchPeople, +} from "@/handlers/api/people.handler"; import { IPerson } from "@/types/person"; import { set } from "date-fns"; import { CommandLoading } from "cmdk"; import { Avatar } from "../ui/avatar"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useToast } from "../ui/use-toast"; +import { Input } from "../ui/input"; +import Loader from "../ui/loader"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { CaretDownIcon, FaceIcon } from "@radix-ui/react-icons"; +import FaceThumbnail from "./merge/FaceThumbnail"; +import ErrorBlock from "../shared/ErrorBlock"; interface PersonMergeDropdownProps { person: IPerson; onRemove?: (person: IPerson) => void; } + export function PersonMergeDropdown({ person, onRemove, }: PersonMergeDropdownProps) { - const [searchedPeople, setSearchedPeople] = useState([]); + const [searchedPeople, setSearchedPeople] = useState(null); + const [similarPeople, setSimilarPeople] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); + const [similarLoading, setSimilarLoading] = useState(true); const searchTimer = useRef(); + const [selectedPeople, setSelectedPeople] = useState([]); + const [primaryPerson, setPrimaryPerson] = useState(person); + const [merging, setMerging] = useState(false); const { toast } = useToast(); + const selectedIds = useMemo( + () => selectedPeople.map((p) => p.id), + [selectedPeople] + ); + const handleSearch = (value: string) => { if (searchTimer.current) { clearTimeout(searchTimer.current); } searchTimer.current = setTimeout(() => { + if (!value || !value.trim().length) { + setSearchedPeople(null); + return; + } setLoading(true); if (!value) { setSearchedPeople([]); @@ -54,23 +85,144 @@ export function PersonMergeDropdown({ }, 500); }; + const fetchSuggestions = () => { + return listSimilarFaces(person.id) + .then(setSimilarPeople) + .catch(() => { + toast({ + title: "Error", + description: "Failed to fetch similar people", + }); + }) + .finally(() => { + setSimilarLoading(false); + }); + }; + const handleSelect = (value: IPerson) => { - setOpen(false); - return mergePerson(person.id, value.id) + const isAlreadySelected = selectedPeople.some((p) => p.id === value.id); + if (isAlreadySelected) { + setSelectedPeople(selectedPeople.filter((p) => p.id !== value.id)); + } + if (selectedPeople.length >= 5) { + toast({ + title: "Error", + description: "You can only merge 5 people at a time", + }); + return; + } else { + setSelectedPeople([...selectedPeople, value]); + } + if (primaryPerson.name.length === 0 && value.name.length > 0) { + setPrimaryPerson(value); + } + // setOpen(false); + // return mergePerson(person.id, value.id) + // .then(() => { + // onRemove?.(person); + // }) + // .then(() => { + // toast({ + // title: "Success", + // description: "Person merged successfully", + // }); + // }); + }; + + const handleMerge = () => { + if (selectedPeople.length === 0) { + return; + } + const personIds = selectedPeople.map((p) => p.id); + setMerging(true); + return mergePerson(person.id, personIds) .then(() => { onRemove?.(person); }) .then(() => { + setOpen(false); toast({ title: "Success", - description: "Person merged successfully", + description: "People merged successfully", }); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to merge people", + }); + }) + .finally(() => { + setMerging(false); }); }; + const handleRemove = (value: IPerson) => { + const newSelected = selectedPeople.filter((p) => p.id !== value.id); + if (primaryPerson.id === value.id) { + const newPrimary = newSelected[0]; + setPrimaryPerson(newPrimary || person); + } + setSelectedPeople(newSelected); + }; + useEffect(() => { - if (open) handleSearch(""); - }, [open]); + if (open && !similarPeople.length) fetchSuggestions(); + }, [open, person.id]); + + const renderPeopleList = (people: IPerson[], title: string) => { + return ( +
+

{title}

+
+ {people.map((person) => ( + + ))} +
+
+ ); + }; + const renderContent = () => { + if (loading || similarLoading) { + return ( +
+ +
+ ); + } + if (searchedPeople && searchedPeople?.length === 0) { + return ( +
+

No Results Found

+
+ ); + } + + if (searchedPeople && searchedPeople?.length > 0) { + return renderPeopleList(searchedPeople, "Search Results"); + } + + if (similarPeople && similarPeople.length === 0) { + return ( + } + /> + ); + } + + if (similarPeople && similarPeople.length > 0) { + return renderPeopleList(similarPeople, "Similar People"); + } + + return

Please search for people

; + }; return (
@@ -80,35 +232,104 @@ export function PersonMergeDropdown({ Merge - - - - - {loading && } - - {searchedPeople.map((person) => ( - { - handleSelect(person); - }} - className="flex items-center gap-2" - > + + + + Merge {person.name ? person.name : "Untagged Person"} + + +
+

+ {selectedPeople.length > 0 + ? `Merging ${selectedPeople.length} people to ${person.name}` + : "Search and select people to merge with"} +

+ + - {person.name} - - ))} - - - +

{primaryPerson?.name || "Untagged Person"}

+ +
+ + {[person, ...selectedPeople].map((person) => ( + setPrimaryPerson(person)} + className="flex gap-1" + > + + {person.name || "Untagged Person"} + + ))} + +
+
+
+
+ { + handleSearch(e.target.value); + }} + /> + + {selectedPeople.length > 0 ? ( +
+ {selectedPeople.map((person) => ( +
+ +

+ {person.name ? person.name : Untagged person} +

+ +
+ ))} +
+ ) : ( +
+

No Selections Yet

+
+ )} + + {renderContent()} + + + +
diff --git a/src/components/people/merge/FaceThumbnail.tsx b/src/components/people/merge/FaceThumbnail.tsx new file mode 100644 index 0000000..b37725c --- /dev/null +++ b/src/components/people/merge/FaceThumbnail.tsx @@ -0,0 +1,35 @@ +import { Avatar } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import { IPerson } from "@/types/person"; + +interface FaceThumbnailProps { + person: IPerson; + onSelect: (person: IPerson) => void; + selected?: boolean; +} +const FaceThumbnail = ({ person, onSelect, selected }: FaceThumbnailProps) => { + return ( +
onSelect(person)} + > + + {person.name ? ( +

{person.name}

+ ) : ( +

Untagged Person

+ )} +
+ ); +}; + +export default FaceThumbnail; diff --git a/src/components/shared/ErrorBlock.tsx b/src/components/shared/ErrorBlock.tsx new file mode 100644 index 0000000..a886429 --- /dev/null +++ b/src/components/shared/ErrorBlock.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +interface ErrorBlockProps { + icon?: React.ReactNode; + title?: string; + description?: string; +} +export default function ErrorBlock( + { icon, title, description }: ErrorBlockProps +) { + return ( +
+ {icon} + {title &&

{title}

} + {description &&

{description}

} +
+ ) +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..cdfb8ce --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,162 @@ +import * as React from "react" +import { + CaretSortIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons" +import * as SelectPrimitive from "@radix-ui/react-select" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 0067432..612cdeb 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -5,6 +5,7 @@ export const GET_ME_PATH = BASE_PROXY_ENDPOINT + "/users/me"; export const LIST_PEOPLE_PATH = BASE_API_ENDPOINT + "/people/list"; export const SEARCH_PEOPLE_PATH = BASE_PROXY_ENDPOINT + "/search/person"; +export const SIMILAR_FACES_PATH = (id: string) => BASE_API_ENDPOINT + "/people/" + id + "/similar-faces"; export const PERSON_THUBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/thumbnail/" + id; export const UPDATE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/" + id; export const MERGE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/" + id + "/merge"; diff --git a/src/handlers/api/people.handler.ts b/src/handlers/api/people.handler.ts index f0c4693..2f8d1fa 100644 --- a/src/handlers/api/people.handler.ts +++ b/src/handlers/api/people.handler.ts @@ -1,4 +1,4 @@ -import { LIST_PEOPLE_PATH, MERGE_PERSON_PATH, SEARCH_PEOPLE_PATH, UPDATE_PERSON_PATH } from "@/config/routes" +import { LIST_PEOPLE_PATH, MERGE_PERSON_PATH, SEARCH_PEOPLE_PATH, SIMILAR_FACES_PATH, UPDATE_PERSON_PATH } from "@/config/routes" import { cleanUpPerson } from "@/helpers/person.helper"; import API from "@/lib/api" import { IPeopleListResponse, IPerson } from "@/types/person" @@ -36,6 +36,10 @@ export const searchPeople = (name: string) => { return API.get(SEARCH_PEOPLE_PATH, { name }).then((response) => response.map(cleanUpPerson)); } -export const mergePerson = (id: string, targetId: string) => { - return API.post(MERGE_PERSON_PATH(id), { ids: [ targetId ] }) +export const mergePerson = (id: string, targetIds: string[]) => { + return API.post(MERGE_PERSON_PATH(id), { ids: targetIds }) +} + +export const listSimilarFaces = (id: string) => { + return API.get(SIMILAR_FACES_PATH(id)).then((response) => response.map(cleanUpPerson)); } \ No newline at end of file diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index ba4cd46..79981b1 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -53,6 +53,7 @@ export default async function handler( isNotNull(assets.createdAt), gte(exif.dateTimeOriginal, startDateDate), lte(exif.dateTimeOriginal, endDateDate), + eq(assets.type, "VIDEO"), eq(assets.ownerId, currentUser.id), )); diff --git a/src/pages/api/assets/missing-location-dates.ts b/src/pages/api/assets/missing-location-dates.ts index c9e7013..2281a53 100644 --- a/src/pages/api/assets/missing-location-dates.ts +++ b/src/pages/api/assets/missing-location-dates.ts @@ -22,6 +22,7 @@ export default async function handler( .leftJoin(exif, eq(exif.assetId, assets.id)) .where(and( isNull(exif.latitude), + eq(assets.type, "VIDEO"), eq(assets.ownerId, currentUser.id), )) .groupBy(sql`DATE(${exif.dateTimeOriginal})`) diff --git a/src/pages/api/people/[id]/similar-faces.ts b/src/pages/api/people/[id]/similar-faces.ts index ea96571..22b2d05 100644 --- a/src/pages/api/people/[id]/similar-faces.ts +++ b/src/pages/api/people/[id]/similar-faces.ts @@ -1,10 +1,11 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import { CHART_COLORS } from "@/config/constants/chart.constant"; import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { stringToBoolean } from "@/helpers/data.helper"; import { assetFaces, assets, exif, person } from "@/schema"; import { faceSearch } from "@/schema/faceSearch.schema"; -import { cosineDistance, desc, eq, gt, ne, sql } from "drizzle-orm"; +import { and, cosineDistance, desc, eq, gt, ne, sql } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; type ISortField = "assetCount" | "updatedAt" | "createdAt"; @@ -33,11 +34,13 @@ export default async function handler( sortOrder = "desc", } = req.query as any as IQuery; + const currentUser = await getCurrentUser(); const personRecords = await db .select() .from(person) .where(eq(person.id, id)) .limit(1); + const personRecord = personRecords?.[0]; if (!personRecord) { return res.status(404).json({ @@ -57,11 +60,12 @@ export default async function handler( error: "Person has no face", }); } + console.log(assetFaceRecord.id); const faceSearchRecords = await db .select() .from(faceSearch) - .where(ne(faceSearch.faceId, assetFaceRecord.id)) + .where(eq(faceSearch.faceId, assetFaceRecord.id)) .limit(1); const faceSearchRecord = faceSearchRecords?.[0]; @@ -77,28 +81,31 @@ export default async function handler( )})`; const people = await db - .select({ + .selectDistinctOn([person.id], { id: person.id, name: person.name, thumbnailPath: person.thumbnailPath, birthDate: person.birthDate, isHidden: person.isHidden, updatedAt: person.updatedAt, - assetId: assetFaces.assetId, + assetId: assetFaces.id, + faceSearch: faceSearch.faceId, similarity, }) .from(faceSearch) .leftJoin(assetFaces, eq(assetFaces.id, faceSearch.faceId)) - .leftJoin(person, eq(person.id, assetFaces.personId)) - .where(gt(similarity, 0.5)) - .limit(4); + .innerJoin(person, eq(person.id, assetFaces.personId)) - console.log(people); + .where( + and( + ne(person.id, id), + eq(person.ownerId, currentUser.id), + gt(similarity, 0.5) + ) + ) + .limit(12); - return res.status(200).json({ - people, - total: 10, - }); + return res.status(200).json(people); } catch (error: any) { res.status(500).json({ error: error?.message,