From bf5d241177c49f86326c8132f24ce465f43a00c6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:36:16 -0500 Subject: [PATCH] use react-dropzone with preview when uploading new face --- web/public/locales/en/views/faceLibrary.json | 17 ++- web/src/components/input/ImageEntry.tsx | 121 ++++++++++++++++--- 2 files changed, 115 insertions(+), 23 deletions(-) diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 0a4444ae5..e2a87433e 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -1,6 +1,7 @@ { "description": { - "addFace": "Walk through adding a new face to the Face Library." + "addFace": "Walk through adding a new collection to the Face Library.", + "placeholder": "Enter a name for this collection" }, "details": { "person": "Person", @@ -15,8 +16,8 @@ "desc": "Upload an image to scan for faces and include for {{pageToggle}}" }, "createFaceLibrary": { - "title": "Create Face Library", - "desc": "Create a new face library", + "title": "Create Collection", + "desc": "Create a new collection", "new": "Create New Face", "nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle." }, @@ -28,7 +29,7 @@ "selectFace": "Select Face", "deleteFaceLibrary": { "title": "Delete Name", - "desc": "Are you sure you want to delete {{name}}? This will permanently delete all associated faces." + "desc": "Are you sure you want to delete the collection {{name}}? This will permanently delete all associated faces." }, "button": { "deleteFaceAttempts": "Delete Face Attempts", @@ -36,6 +37,14 @@ "uploadImage": "Upload Image", "reprocessFace": "Reprocess Face" }, + "imageEntry": { + "validation": { + "selectImage": "Please select an image file." + }, + "dropActive": "Drop the image here...", + "dropInstructions": "Drag and drop an image here, or click to select", + "maxSize": "Max size: {{size}}MB" + }, "readTheDocs": "Read the documentation to view more details on refining images for the Face Library", "trainFaceAs": "Train Face as:", "trainFace": "Train Face", diff --git a/web/src/components/input/ImageEntry.tsx b/web/src/components/input/ImageEntry.tsx index afb399177..1e64840be 100644 --- a/web/src/components/input/ImageEntry.tsx +++ b/web/src/components/input/ImageEntry.tsx @@ -1,38 +1,82 @@ -import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import React, { useCallback } from "react"; +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; import { useForm } from "react-hook-form"; - +import { useTranslation } from "react-i18next"; +import { LuUpload, LuX } from "react-icons/lu"; import { z } from "zod"; type ImageEntryProps = { onSave: (file: File) => void; children?: React.ReactNode; + maxSize?: number; + accept?: Record; }; -export default function ImageEntry({ onSave, children }: ImageEntryProps) { + +export default function ImageEntry({ + onSave, + children, + maxSize = 10 * 1024 * 1024, // 10MB default + accept = { "image/*": [".jpeg", ".jpg", ".png", ".gif", ".webp"] }, +}: ImageEntryProps) { + const { t } = useTranslation(["views/faceLibrary"]); + const [preview, setPreview] = useState(null); + const formSchema = z.object({ - file: z.instanceof(FileList, { message: "Please select an image file." }), + file: z.instanceof(File, { message: "Please select an image file." }), }); const form = useForm>({ resolver: zodResolver(formSchema), }); - const fileRef = form.register("file"); - // upload handler + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + form.setValue("file", file, { shouldValidate: true }); + + // Create preview + const objectUrl = URL.createObjectURL(file); + setPreview(objectUrl); + + // Clean up preview URL when component unmounts + return () => URL.revokeObjectURL(objectUrl); + } + }, + [form], + ); + + const { getRootProps, getInputProps, isDragActive, isDragReject } = + useDropzone({ + onDrop, + maxSize, + accept, + multiple: false, + }); const onSubmit = useCallback( (data: z.infer) => { - if (!data["file"] || Object.keys(data.file).length == 0) { - return; - } - - onSave(data["file"]["0"]); + if (!data.file) return; + onSave(data.file); }, [onSave], ); + const clearSelection = () => { + form.reset(); + setPreview(null); + }; + return (
@@ -42,16 +86,55 @@ export default function ImageEntry({ onSave, children }: ImageEntryProps) { render={() => ( - +
+ {!preview ? ( +
+ + +

+ {isDragActive + ? t("imageEntry.dropActive") + : t("imageEntry.dropInstructions")} +

+

+ {t("imageEntry.maxSize", { + size: Math.round(maxSize / (1024 * 1024)), + })} +

+
+ ) : ( +
+ Preview + +
+ )} +
+
)} /> - {children} +
{children}
);