diff --git a/web/package-lock.json b/web/package-lock.json index 986677695..ebcdba519 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -56,6 +56,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", + "react-dropzone": "^14.3.8", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", "react-i18next": "^15.2.0", @@ -3526,6 +3527,15 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -5112,6 +5122,18 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -7221,6 +7243,23 @@ "node": ">=6" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-grid-layout": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz", @@ -8548,9 +8587,10 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/web/package.json b/web/package.json index 37233a976..7bcffad79 100644 --- a/web/package.json +++ b/web/package.json @@ -62,6 +62,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", + "react-dropzone": "^14.3.8", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", "react-i18next": "^15.2.0", diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 0a4444ae5..ee3dc2c29 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,10 +16,10 @@ "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." + "nextSteps": "To build a strong foundation:
  • Use the Train tab to select and train on images for each detected person.
  • Focus on straight-on images for best results; avoid training images that capture faces at an angle.
  • " }, "train": { "title": "Train", @@ -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,7 +37,15 @@ "uploadImage": "Upload Image", "reprocessFace": "Reprocess Face" }, - "readTheDocs": "Read the documentation to view more details on refining images for the Face Library", + "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", "trainFaceAs": "Train Face as:", "trainFace": "Train Face", "toast": { 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}
    ); diff --git a/web/src/components/input/TextEntry.tsx b/web/src/components/input/TextEntry.tsx index c9fa8a8a9..92867f5e3 100644 --- a/web/src/components/input/TextEntry.tsx +++ b/web/src/components/input/TextEntry.tsx @@ -1,4 +1,10 @@ -import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { zodResolver } from "@hookform/resolvers/zod"; import React, { useCallback } from "react"; @@ -14,50 +20,47 @@ type TextEntryProps = { children?: React.ReactNode; }; export default function TextEntry({ - defaultValue, + defaultValue = "", placeholder, - allowEmpty, + allowEmpty = false, onSave, children, }: TextEntryProps) { const formSchema = z.object({ - text: z.string(), + text: allowEmpty + ? z.string().optional() + : z.string().min(1, "Field is required"), }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { text: defaultValue }, }); - const fileRef = form.register("text"); - - // upload handler const onSubmit = useCallback( (data: z.infer) => { - if (!allowEmpty && !data["text"]) { - return; - } - onSave(data["text"]); + onSave(data.text || ""); }, - [onSave, allowEmpty], + [onSave], ); return (
    - + ( + render={({ field }) => ( + )} /> diff --git a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx index 659ac4c88..00e4b5c5f 100644 --- a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx +++ b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx @@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"; import axios from "axios"; import { useCallback, useState } from "react"; import { isDesktop } from "react-device-detect"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { LuExternalLink } from "react-icons/lu"; import { Link } from "react-router-dom"; import { toast } from "sonner"; @@ -101,7 +101,7 @@ export default function CreateFaceWizardDialog({ }} >
    {t("button.addFace")} @@ -110,7 +110,7 @@ export default function CreateFaceWizardDialog({ {step == 0 && ( { setName(name); setStep(1); @@ -133,12 +133,16 @@ export default function CreateFaceWizardDialog({ )} {step == 2 && ( -
    +
    {t("toast.success.addFaceLibrary", { name })} -

    - {t("createFaceLibrary.nextSteps")} +

    +

      + + createFaceLibrary.nextSteps + +

    -
    +