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