From 2066b9fb4b7ef896c5a489a09a04dd26e6c4e59f Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Fri, 27 Mar 2026 11:41:44 +0000 Subject: [PATCH] feat: crop portrait and split portrait+features image --- package.json | 1 + pnpm-lock.yaml | 28 ++++-- src/app/api/submit/route.ts | 28 ++++-- src/app/layout.tsx | 1 + src/app/mii/[id]/image/route.ts | 4 +- src/app/mii/[id]/page.tsx | 10 +- src/components/mii/list/index.tsx | 2 +- .../{qr-scanner.tsx => camera.tsx} | 39 +++++--- src/components/submit-form/crop-portrait.tsx | 95 +++++++++++++++++++ src/components/submit-form/index.tsx | 29 +++--- .../submit-form/portrait-upload.tsx | 45 --------- .../submit-form/switch-file-upload.tsx | 77 +++++++++++++++ 12 files changed, 269 insertions(+), 90 deletions(-) rename src/components/submit-form/{qr-scanner.tsx => camera.tsx} (84%) create mode 100644 src/components/submit-form/crop-portrait.tsx delete mode 100644 src/components/submit-form/portrait-upload.tsx create mode 100644 src/components/submit-form/switch-file-upload.tsx diff --git a/package.json b/package.json index b7eddc1..fe3053c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-dropzone": "^15.0.0", + "react-image-crop": "^11.0.10", "redis": "^5.11.0", "satori": "^0.25.0", "seedrandom": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dbe395..d2d163c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react-dropzone: specifier: ^15.0.0 version: 15.0.0(react@19.2.4) + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@19.2.4) redis: specifier: ^5.11.0 version: 5.11.0 @@ -2934,6 +2937,11 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5294,8 +5302,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.0 eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@10.0.3(jiti@2.6.1)) @@ -5317,7 +5325,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -5328,22 +5336,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.3(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.3(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5354,7 +5362,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.3(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6289,6 +6297,10 @@ snapshots: prop-types: 15.8.1 react: 19.2.4 + react-image-crop@11.0.10(react@19.2.4): + dependencies: + react: 19.2.4 + react-is@16.13.1: {} react-is@18.3.1: {} diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 98f790b..d35af49 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -33,6 +33,7 @@ const submitSchema = z // Switch gender: z.enum(MiiGender).default("MALE"), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), instructions: switchMiiInstructionsSchema, // QR code @@ -51,15 +52,15 @@ const submitSchema = z // This refine function is probably useless .refine( (data) => { - // If platform is Switch, gender and miiPortraitImage must be present + // If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present if (data.platform === "SWITCH") { return data.gender !== undefined && data.miiPortraitImage !== undefined; } return true; }, { - message: "Gender, Mii portrait image, and instructions are required for Switch platform", - path: ["gender", "miiPortraitImage", "instructions"], + message: "Gender, Mii portrait & features image, and instructions are required for Switch platform", + path: ["gender", "miiPortraitImage", "miiFeaturesImage", "instructions"], }, ); @@ -129,6 +130,7 @@ export async function POST(request: NextRequest) { gender: formData.get("gender") ?? undefined, // ZOD MOMENT miiPortraitImage: formData.get("miiPortraitImage"), + miiFeaturesImage: formData.get("miiFeaturesImage"), instructions: minifiedInstructions, qrBytesRaw: rawQrBytesRaw, @@ -159,6 +161,7 @@ export async function POST(request: NextRequest) { qrBytesRaw, gender, miiPortraitImage, + miiFeaturesImage, image1, image2, image3, @@ -183,10 +186,12 @@ export async function POST(request: NextRequest) { } } - // Check Mii portrait image as well (Switch) + // Check Mii portrait & features image (Switch) if (platform === "SWITCH") { - const imageValidation = await validateImage(miiPortraitImage); - if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); + const portraitValidation = await validateImage(miiPortraitImage); + const featuresValidation = await validateImage(miiFeaturesImage); + if (!portraitValidation.valid) return rateLimit.sendResponse({ error: portraitValidation.error }, portraitValidation.status ?? 400); + if (!featuresValidation.valid) return rateLimit.sendResponse({ error: featuresValidation.error }, featuresValidation.status ?? 400); } const qrBytes = new Uint8Array(qrBytesRaw ?? []); @@ -246,8 +251,15 @@ export async function POST(request: NextRequest) { portraitBuffer = Buffer.from(await studioResponse.arrayBuffer()); } else if (platform === "SWITCH") { portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer()); + + // Save features image + const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer()); + const pngBuffer = await sharp(featuresBuffer).png({ quality: 85 }).toBuffer(); + const fileLocation = path.join(miiUploadsDirectory, "features.png"); + await fs.writeFile(fileLocation, pngBuffer); } + // Save portrait image if (!portraitBuffer) throw Error("Mii portrait buffer not initialised"); const pngBuffer = await sharp(portraitBuffer).png({ quality: 85 }).toBuffer(); const fileLocation = path.join(miiUploadsDirectory, "mii.png"); @@ -258,9 +270,9 @@ export async function POST(request: NextRequest) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Failed to download/store Mii portrait:", error); + console.error("Failed to download/store Mii portrait/features:", error); Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } }); - return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); + return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait/features" }, 500); } if (platform === "THREE_DS") { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e73dacd..1a83b13 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Lexend } from "next/font/google"; import { WebSite, WithContext } from "schema-dts"; import "./globals.css"; +import "react-image-crop/dist/ReactCrop.css"; import Providers from "./provider"; import Header from "@/components/header"; diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index d6d553d..406991e 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -12,8 +12,8 @@ import { prisma } from "@/lib/prisma"; const searchParamsSchema = z.object({ type: z - .enum(["mii", "qr-code", "image0", "image1", "image2", "metadata"], { - message: "Image type must be either 'mii', 'qr-code', 'image[number from 0 to 2]' or 'metadata'", + .enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], { + message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'", }) .default("mii"), }); diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 5c9fb13..834bced 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -135,7 +135,7 @@ export default async function MiiPage({ params }: Props) { /> {/* QR Code */} - {mii.platform === "THREE_DS" && ( + {mii.platform === "THREE_DS" ? (
+ ) : ( + )}
diff --git a/src/components/mii/list/index.tsx b/src/components/mii/list/index.tsx index 34eb639..3be9b00 100644 --- a/src/components/mii/list/index.tsx +++ b/src/components/mii/list/index.tsx @@ -197,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro `/mii/${mii.id}/image?type=image${index}`), ]} /> diff --git a/src/components/submit-form/qr-scanner.tsx b/src/components/submit-form/camera.tsx similarity index 84% rename from src/components/submit-form/qr-scanner.tsx rename to src/components/submit-form/camera.tsx index 609b4d6..5045cd6 100644 --- a/src/components/submit-form/qr-scanner.tsx +++ b/src/components/submit-form/camera.tsx @@ -10,10 +10,11 @@ import { useSelect } from "downshift"; interface Props { isOpen: boolean; setIsOpen: React.Dispatch>; - setQrBytesRaw: React.Dispatch>; + setImage?: React.Dispatch>; + setQrBytesRaw?: React.Dispatch>; } -export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { +export default function Camera({ isOpen, setIsOpen, setImage, setQrBytesRaw }: Props) { const [isVisible, setIsVisible] = useState(false); const [permissionGranted, setPermissionGranted] = useState(null); @@ -45,11 +46,11 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { }, }); - const scanQRCode = useCallback(() => { + const takePicture = useCallback(() => { if (!isOpen) return; // Continue scanning in a loop - requestRef.current = requestAnimationFrame(scanQRCode); + if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture); const video = videoRef.current; const canvas = canvasRef.current; @@ -62,6 +63,13 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { canvas.height = video.videoHeight; ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); + if (setImage) { + setImage(canvas.toDataURL()); + close(); + return; + } + if (!setQrBytesRaw) return; + const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight); const code = jsQR(imageData.data, imageData.width, imageData.height); if (!code || !code.binaryData) return; @@ -73,7 +81,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { } setQrBytesRaw(code.binaryData); - setIsOpen(false); + close(); }, [isOpen, setIsOpen, setQrBytesRaw]); const requestPermission = () => { @@ -129,7 +137,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { }) .catch((err) => console.error("Camera error", err)); - requestRef.current = requestAnimationFrame(scanQRCode); + if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture); // cleanup return () => { @@ -142,7 +150,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { videoRef.current.srcObject = null; } }; - }, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]); + }, [isOpen, permissionGranted, selectedDeviceId, takePicture]); return (
@@ -157,7 +165,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { }`} >
-

Scan QR Code

+

{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}

@@ -199,26 +207,31 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
-
+
{!permissionGranted && (

Camera access denied

-

Please allow camera access in your browser settings to scan QR codes

+

Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}

)} -
-
+
+ {setImage && ( + + )}
diff --git a/src/components/submit-form/crop-portrait.tsx b/src/components/submit-form/crop-portrait.tsx new file mode 100644 index 0000000..fd2ed13 --- /dev/null +++ b/src/components/submit-form/crop-portrait.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import ReactCrop, { Crop } from "react-image-crop"; +import { Icon } from "@iconify/react"; + +interface Props { + isOpen: boolean; + setIsOpen: React.Dispatch>; + image: string | undefined; + setImage: React.Dispatch>; +} + +export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Props) { + const [isVisible, setIsVisible] = useState(false); + const [crop, setCrop] = useState(); + + const imageRef = useRef(null); + const canvasRef = useRef(null); + + const applyCrop = useCallback(() => { + if (!imageRef.current || !canvasRef.current || !crop) return; + + const image = imageRef.current; + const canvas = canvasRef.current; + + if (!crop.width || !crop.height || image.naturalWidth === 0 || image.naturalHeight === 0) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + + canvas.width = crop.width; + canvas.height = crop.height; + + ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height); + + setImage(canvas.toDataURL()); + close(); + }, [crop, setImage]); + + const close = () => { + setIsVisible(false); + setTimeout(() => { + setIsOpen(false); + }, 300); + }; + + useEffect(() => { + if (isOpen) { + // slight delay to trigger animation + setTimeout(() => setIsVisible(true), 10); + } + }, [isOpen]); + + return ( +
+
+ +
+
+

Crop Portrait

+ +
+ +
+ setCrop(c)} className="rounded-2xl border-2 border-amber-500 overflow-hidden max-h-96"> + + + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/submit-form/index.tsx b/src/components/submit-form/index.tsx index 009e2a1..74b9a2d 100644 --- a/src/components/submit-form/index.tsx +++ b/src/components/submit-form/index.tsx @@ -17,9 +17,9 @@ import { SwitchMiiInstructions } from "@/types"; import TagSelector from "../tag-selector"; import ImageList from "./image-list"; -import PortraitUpload from "./portrait-upload"; +import SwitchFileUpload from "./switch-file-upload"; import QrUpload from "./qr-upload"; -import QrScanner from "./qr-scanner"; +import Camera from "./camera"; import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit"; import MiiEditor from "./mii-editor"; import SwitchSubmitTutorialButton from "../tutorial/switch-submit"; @@ -41,6 +41,7 @@ export default function SubmitForm() { const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); const [miiPortraitUri, setMiiPortraitUri] = useState(); + const [miiFeaturesUri, setMiiFeaturesUri] = useState(); const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState(); const [name, setName] = useState(""); @@ -119,21 +120,24 @@ export default function SubmitForm() { if (platform === "THREE_DS") { formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); } else if (platform === "SWITCH") { - const response = await fetch(miiPortraitUri!); + const portraitResponse = await fetch(miiPortraitUri!); + const featuresResponse = await fetch(miiFeaturesUri!); - if (!response.ok) { - setError("Failed to check Mii portrait. Did you upload one?"); + if (!portraitResponse.ok || !featuresResponse.ok) { + setError("Failed to get Mii portrait/features screenshot. Did you upload one?"); return; } - const blob = await response.blob(); - if (!blob.type.startsWith("image/")) { - setError("Invalid image file returned"); + const portraitBlob = await portraitResponse.blob(); + const featuresBlob = await featuresResponse.blob(); + if (!portraitBlob.type.startsWith("image/") || !featuresBlob.type.startsWith("image/")) { + setError("Invalid image file found"); return; } formData.append("gender", gender); - formData.append("miiPortraitImage", blob); + formData.append("miiPortraitImage", portraitBlob); + formData.append("miiFeaturesImage", featuresBlob); formData.append("instructions", JSON.stringify(instructions.current)); } @@ -197,7 +201,7 @@ export default function SubmitForm() { URL.createObjectURL(file)), ]} /> @@ -369,7 +373,8 @@ export default function SubmitForm() {
- + +
@@ -393,7 +398,7 @@ export default function SubmitForm() { Use your camera - + For emulators, aes_keys.txt is required. diff --git a/src/components/submit-form/portrait-upload.tsx b/src/components/submit-form/portrait-upload.tsx deleted file mode 100644 index 0a6db2b..0000000 --- a/src/components/submit-form/portrait-upload.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { FileWithPath } from "react-dropzone"; -import Dropzone from "../dropzone"; - -interface Props { - setImage: React.Dispatch>; -} - -export default function PortraitUpload({ setImage }: Props) { - const [hasImage, setHasImage] = useState(false); - - const handleDrop = useCallback( - (acceptedFiles: FileWithPath[]) => { - const file = acceptedFiles[0]; - // Convert to Data URI - const reader = new FileReader(); - reader.onload = async (event) => { - setImage(event.target!.result as string); - setHasImage(true); - }; - reader.readAsDataURL(file); - }, - [setImage], - ); - - return ( -
- -

- {!hasImage ? ( - <> - Drag and drop a screenshot of your Mii's features here -
- or click to open - - ) : ( - "Uploaded!" - )} -

-
-
- ); -} diff --git a/src/components/submit-form/switch-file-upload.tsx b/src/components/submit-form/switch-file-upload.tsx new file mode 100644 index 0000000..1bd2fea --- /dev/null +++ b/src/components/submit-form/switch-file-upload.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { FileWithPath } from "react-dropzone"; +import { Icon } from "@iconify/react"; +import Dropzone from "../dropzone"; +import Camera from "./camera"; +import CropPortrait from "./crop-portrait"; + +interface Props { + text: string; + hasCrop?: boolean; + image?: string | undefined; + setImage: React.Dispatch>; +} + +export default function SwitchFileUpload({ text, hasCrop = false, image, setImage }: Props) { + const [isCameraOpen, setIsCameraOpen] = useState(false); + const [isCropOpen, setIsCropOpen] = useState(false); + const [hasImage, setHasImage] = useState(false); + + const handleDrop = useCallback( + (acceptedFiles: FileWithPath[]) => { + const file = acceptedFiles[0]; + // Convert to Data URI + const reader = new FileReader(); + reader.onload = async (event) => { + setImage(event.target!.result as string); + setHasImage(true); + if (hasCrop) setIsCropOpen(true); + }; + reader.readAsDataURL(file); + }, + [setImage], + ); + + useEffect(() => { + if (!isCameraOpen) return; + if (hasCrop) setIsCropOpen(true); + }, [isCameraOpen]); + + return ( +
+ +

+ {!hasImage ? ( + <> + Drag and drop {text} +
+ or click to open + + ) : ( + "Uploaded!" + )} +

+
+ + or + + + {hasCrop && ( + <> + + + + )} + + +
+ ); +}