diff --git a/digit-recognition/frontend/package.json b/digit-recognition/frontend/package.json index b15203c..1417e66 100644 --- a/digit-recognition/frontend/package.json +++ b/digit-recognition/frontend/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.59.20", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-easy-crop": "^5.4.1", "wagmi": "^2.12.29" }, "devDependencies": { diff --git a/digit-recognition/frontend/src/components/index.ts b/digit-recognition/frontend/src/components/index.ts index 57cc6d9..bbf4c13 100644 --- a/digit-recognition/frontend/src/components/index.ts +++ b/digit-recognition/frontend/src/components/index.ts @@ -3,3 +3,5 @@ export { Layout } from "./layout/Layout"; export { WalletButton } from "./wallet/WalletButton"; export { Button } from "./ui/button/Button"; export { Card } from "./ui/card/Card"; +export { Modal } from "./ui/modal"; +export { ImageCrop } from "./ui/image-crop"; diff --git a/digit-recognition/frontend/src/components/ui/button/Button.tsx b/digit-recognition/frontend/src/components/ui/button/Button.tsx index 3e76938..3c9d2b8 100644 --- a/digit-recognition/frontend/src/components/ui/button/Button.tsx +++ b/digit-recognition/frontend/src/components/ui/button/Button.tsx @@ -38,6 +38,7 @@ const Button: React.FC = ({ onClick={onClick} disabled={isDisabled} aria-disabled={isDisabled} + type="button" > {isLoading ? ( diff --git a/digit-recognition/frontend/src/components/ui/image-crop/image-crop.module.scss b/digit-recognition/frontend/src/components/ui/image-crop/image-crop.module.scss new file mode 100644 index 0000000..b0d0bb5 --- /dev/null +++ b/digit-recognition/frontend/src/components/ui/image-crop/image-crop.module.scss @@ -0,0 +1,21 @@ +.crop-modal { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; +} + +.cropContainer { + position: relative; + width: 100%; + height: 300px;; + background: #000; + margin-bottom: 16px; +} + +.cropControls { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} diff --git a/digit-recognition/frontend/src/components/ui/image-crop/image-crop.tsx b/digit-recognition/frontend/src/components/ui/image-crop/image-crop.tsx new file mode 100644 index 0000000..866e115 --- /dev/null +++ b/digit-recognition/frontend/src/components/ui/image-crop/image-crop.tsx @@ -0,0 +1,57 @@ +import React, { useState, useCallback } from "react"; +import Cropper, { Area } from "react-easy-crop"; + +import { getCroppedImg } from "@/lib/utils"; + +import styles from "./image-crop.module.scss"; +import { Button } from "../button/Button"; + +type Props = { + image: string; + onClose: () => void; + onCropComplete: (croppedImage: Blob) => void; +}; + +const ImageCrop: React.FC = ({ image, onClose, onCropComplete }) => { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const onCropDone = async () => { + if (!croppedAreaPixels) return; + const croppedImage = await getCroppedImg(image, croppedAreaPixels); + onCropComplete(croppedImage); + onClose(); + }; + + const onCropCompleteHandler = useCallback( + (_croppedArea: Area, croppedPixels: Area) => { + setCroppedAreaPixels(croppedPixels); + }, + [] + ); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export { ImageCrop }; diff --git a/digit-recognition/frontend/src/components/ui/image-crop/index.ts b/digit-recognition/frontend/src/components/ui/image-crop/index.ts new file mode 100644 index 0000000..2bf69df --- /dev/null +++ b/digit-recognition/frontend/src/components/ui/image-crop/index.ts @@ -0,0 +1 @@ +export { ImageCrop } from "./image-crop"; diff --git a/digit-recognition/frontend/src/components/ui/modal/index.ts b/digit-recognition/frontend/src/components/ui/modal/index.ts new file mode 100644 index 0000000..18fc26b --- /dev/null +++ b/digit-recognition/frontend/src/components/ui/modal/index.ts @@ -0,0 +1 @@ +export { Modal } from "./modal"; diff --git a/digit-recognition/frontend/src/components/ui/modal/modal.module.scss b/digit-recognition/frontend/src/components/ui/modal/modal.module.scss new file mode 100644 index 0000000..6fdb694 --- /dev/null +++ b/digit-recognition/frontend/src/components/ui/modal/modal.module.scss @@ -0,0 +1,25 @@ +.dialog { + padding: 16px; + width: 438px; + background-color: #000; + border: 1px solid #ffffff80; + color: #fff; + + &::backdrop { + backdrop-filter: blur(4px); + background-color: rgba(0, 0, 0, 0.6); + } +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + margin-bottom: 16px; + + h2 { + margin: 0; + font-size: 16px; + } +} diff --git a/mandelbrot-set/frontend/src/components/ui/Modal.tsx b/digit-recognition/frontend/src/components/ui/modal/modal.tsx similarity index 56% rename from mandelbrot-set/frontend/src/components/ui/Modal.tsx rename to digit-recognition/frontend/src/components/ui/modal/modal.tsx index 9cdba5c..f93c29c 100644 --- a/mandelbrot-set/frontend/src/components/ui/Modal.tsx +++ b/digit-recognition/frontend/src/components/ui/modal/modal.tsx @@ -1,16 +1,26 @@ +import clsx from "clsx"; import { ReactNode, useEffect, useRef, MouseEvent } from "react"; + import CrossIcon from "@/assets/icons/cross.svg?react"; import { Button } from "@/components"; -import { cn } from "@/lib/utils"; + +import styles from "./modal.module.scss"; type Props = { heading?: string; children: ReactNode; onClose: () => void; className?: string; + closeOnBackdropClick?: boolean; }; -function Modal({ heading, children, onClose, className }: Props) { +function Modal({ + heading, + children, + onClose, + className, + closeOnBackdropClick = false, +}: Props) { const ref = useRef(null); const disableScroll = () => document.body.classList.add("modal-open"); @@ -36,30 +46,24 @@ function Modal({ heading, children, onClose, className }: Props) { const handleClick = ({ target }: MouseEvent) => { const isBackdropClick = target === ref.current; - if (isBackdropClick) onClose(); + if (isBackdropClick && closeOnBackdropClick) onClose(); }; return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -
-
-

{heading}

+
+

{heading}

- -
+ +
- {children} -
+ {children}
); } diff --git a/digit-recognition/frontend/src/features/cat-identifier/CatIdentifier.tsx b/digit-recognition/frontend/src/features/cat-identifier/CatIdentifier.tsx index ac81fb2..ccbce1e 100644 --- a/digit-recognition/frontend/src/features/cat-identifier/CatIdentifier.tsx +++ b/digit-recognition/frontend/src/features/cat-identifier/CatIdentifier.tsx @@ -1,4 +1,4 @@ -import { Button, Card } from "@/components"; +import { Button, Card, ImageCrop, Modal } from "@/components"; import { useRef, useState } from "react"; import { handleImageToPixels } from "./utils"; import { useReadRpcState } from "./api/readRpcState"; @@ -9,8 +9,11 @@ import styles from "./CatIdentifier.module.scss"; export const CatIdentifier = () => { const [image, setImage] = useState(null); + const [croppedImage, setCroppedImage] = useState(null); const [isSubmited, setIsSubmited] = useState(false); const [isSubmiting, setIsSubmiting] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const fileInputRef = useRef(null); const onSuccess = () => { @@ -18,6 +21,10 @@ export const CatIdentifier = () => { setIsSubmited(true); }; + const onModalClose = () => { + setIsModalOpen(false); + }; + const onError = () => setIsSubmiting(false); const { rpcState, rpcStatePending } = useReadRpcState({ @@ -30,7 +37,6 @@ export const CatIdentifier = () => { isSubmited && rpcState && rpcState.calculated ? getFloatingPoint(rpcState.probability) : null; - console.log("CatIdentifier probability:", probability); const isPending = rpcStatePending || isSubmiting; @@ -58,6 +64,7 @@ export const CatIdentifier = () => { reader.onload = () => { const imgSrc = reader.result as string; setImage(imgSrc); + setIsModalOpen(true); }; reader.readAsDataURL(file); } @@ -66,6 +73,10 @@ export const CatIdentifier = () => { const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; onFileChange(file); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } }; const handleDragOver = (event: React.DragEvent) => { @@ -79,9 +90,9 @@ export const CatIdentifier = () => { }; const onSubmit = async () => { - if (!image) return; + if (!croppedImage) return; setIsSubmiting(true); - const pixels = await handleImageToPixels(image); + const pixels = await handleImageToPixels(croppedImage); catsPredict(pixels); }; @@ -91,77 +102,91 @@ export const CatIdentifier = () => { }; return ( - - {image ? ( - Uploaded - ) : ( -

OR DRAG AND DROP IMAGE HERE

- )} - - } - headerSlot={ - isSubmited || isPending ? null : ( + <> + + {croppedImage ? ( + Uploaded + ) : ( +

OR DRAG AND DROP IMAGE HERE

+ )} + + } + headerSlot={ + isSubmited || isPending ? null : ( + <> + + + + ) + } + footer={ <> - - + {croppedImage && result === null && ( + + )} + {result !== null && ( + + )} - ) - } - footer={ - <> - {image && result === null && ( - - )} - {result !== null && ( - - )} - - } - /> + } + /> + {isModalOpen && image && ( + + { + const url = URL.createObjectURL(blob); + setCroppedImage(url); + }} + /> + + )} + ); }; diff --git a/digit-recognition/frontend/src/lib/utils/get-cropped-image.ts b/digit-recognition/frontend/src/lib/utils/get-cropped-image.ts new file mode 100644 index 0000000..d0d4eb2 --- /dev/null +++ b/digit-recognition/frontend/src/lib/utils/get-cropped-image.ts @@ -0,0 +1,33 @@ +import { Area } from "react-easy-crop"; + +function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.src = imageSrc; + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return reject("Canvas context not available"); + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + canvas.toBlob((blob) => { + if (!blob) return reject("Canvas is empty"); + resolve(blob); + }, "image/jpeg"); + }; + image.onerror = () => reject("Failed to load image"); + }); +} + +export { getCroppedImg }; diff --git a/digit-recognition/frontend/src/lib/utils/index.ts b/digit-recognition/frontend/src/lib/utils/index.ts index 6fc8790..f9e853d 100644 --- a/digit-recognition/frontend/src/lib/utils/index.ts +++ b/digit-recognition/frontend/src/lib/utils/index.ts @@ -1,6 +1,7 @@ import { FixedPoint } from "../types"; export { isMobileDevice, isMobile } from "./device-detection"; export { retryWhileDataChanged } from "./retry-while-data-changed"; +export { getCroppedImg } from "./get-cropped-image"; export const copyToClipboard = async ({ value, diff --git a/digit-recognition/frontend/yarn.lock b/digit-recognition/frontend/yarn.lock index 47a8289..48ff7a4 100644 --- a/digit-recognition/frontend/yarn.lock +++ b/digit-recognition/frontend/yarn.lock @@ -3689,6 +3689,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +normalize-wheel@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45" + integrity sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -4019,6 +4024,14 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-easy-crop@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/react-easy-crop/-/react-easy-crop-5.4.1.tgz#41aebb2d602dc3cdf2c2c17df1af776c40188ce0" + integrity sha512-Djtsi7bWO75vkKYkVxNRrJWY69pXLahIAkUN0mmt9cXNnaq2tpG59ctSY6P7ipJgBc7COJDRMRuwb2lYwtACNQ== + dependencies: + normalize-wheel "^1.0.1" + tslib "^2.0.1" + react-refresh@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" @@ -4390,7 +4403,7 @@ tslib@1.14.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.6.0, tslib@^2.7.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.6.0, tslib@^2.7.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== diff --git a/mandelbrot-set/frontend/src/components/index.ts b/mandelbrot-set/frontend/src/components/index.ts index b1c15be..3a8da8f 100644 --- a/mandelbrot-set/frontend/src/components/index.ts +++ b/mandelbrot-set/frontend/src/components/index.ts @@ -8,4 +8,3 @@ export { WalletButton } from "./wallet/WalletButton"; export { Button } from "./ui/Button"; export { EncryptButton } from "./ui/EncryptButton"; export { Input } from "./ui/Input"; -export { Modal } from "./ui/Modal";