From 20f1c51f0c3586e7f53690738c9c3bd99cd1bb79 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 13 Sep 2025 14:53:17 +0100 Subject: [PATCH] feat: groundwork for 'living the dream' mii submissions Based on the screenshots from yesterday's Nintendo Direct, it is presumed that the Mii editor in "Living the Dream" is similar to Miitopia's one. This commit lays the groundwork for Miis created in the sequel game. However, due to the way TomodachiShare generates portraits of the Miis, I can't do that unless there is a way to parse the QR code data and render the Mii. Note: I don't know if Nintendo will use access codes (as was the case with Miitopia) therefore, as a precaution, another branch will be created in anticipation for that. --- .../migration.sql | 9 + prisma/schema.prisma | 27 ++- src/app/api/mii/[id]/edit/route.ts | 7 +- src/app/api/submit/route.ts | 162 ++++++++++----- src/app/mii/[id]/image/route.ts | 10 +- src/app/mii/[id]/page.tsx | 36 ++-- src/components/submit-form/index.tsx | 179 ++++++++++++---- .../submit-form/portrait-upload.tsx | 36 ++++ src/components/submit-form/qr-scanner.tsx | 196 +++++++++--------- src/components/submit-form/qr-upload.tsx | 42 ++-- src/components/tutorial/3ds-submit.tsx | 131 ++++++++++++ .../{submit.tsx => switch-submit.tsx} | 2 +- src/lib/images.tsx | 37 ++-- 13 files changed, 612 insertions(+), 262 deletions(-) create mode 100644 prisma/migrations/20250913120915_switch_sequel/migration.sql create mode 100644 src/components/submit-form/portrait-upload.tsx create mode 100644 src/components/tutorial/3ds-submit.tsx rename src/components/tutorial/{submit.tsx => switch-submit.tsx} (98%) diff --git a/prisma/migrations/20250913120915_switch_sequel/migration.sql b/prisma/migrations/20250913120915_switch_sequel/migration.sql new file mode 100644 index 0000000..f16a8bd --- /dev/null +++ b/prisma/migrations/20250913120915_switch_sequel/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS'); + +-- AlterTable +ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS', +ALTER COLUMN "firstName" DROP NOT NULL, +ALTER COLUMN "lastName" DROP NOT NULL, +ALTER COLUMN "islandName" DROP NOT NULL, +ALTER COLUMN "allowedCopying" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebd21f2..e3f09b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,18 +68,20 @@ model Session { } model Mii { - id Int @id @default(autoincrement()) - userId Int - name String @db.VarChar(64) - imageCount Int @default(0) - tags String[] - description String? @db.VarChar(256) + id Int @id @default(autoincrement()) + userId Int - firstName String - lastName String + name String @db.VarChar(64) + imageCount Int @default(0) + tags String[] + description String? @db.VarChar(256) + platform MiiPlatform @default(THREE_DS) + + firstName String? + lastName String? gender MiiGender? - islandName String - allowedCopying Boolean + islandName String? + allowedCopying Boolean? createdAt DateTime @default(now()) @@ -153,6 +155,11 @@ model Punishment { @@map("punishments") } +enum MiiPlatform { + SWITCH + THREE_DS // can't start with a number +} + enum MiiGender { MALE FEMALE diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 6e42dc8..293714b 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -136,7 +136,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } else if (description === undefined) { // If images or description were not changed, regenerate the metadata image - await generateMetadataImage(mii, mii.user.username!); + try { + await generateMetadataImage(mii, mii.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); + } } return rateLimit.sendResponse({ success: true }); diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index fa611ed..070546c 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -7,7 +7,7 @@ import sharp from "sharp"; import qrcode from "qrcode-generator"; import { profanity } from "@2toad/profanity"; -import { MiiGender } from "@prisma/client"; +import { MiiGender, MiiPlatform } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -21,29 +21,52 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); -const submitSchema = z.object({ - name: nameSchema, - tags: tagsSchema, - description: z.string().trim().max(256).optional(), - qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }), - image1: z.union([z.instanceof(File), z.any()]).optional(), - image2: z.union([z.instanceof(File), z.any()]).optional(), - image3: z.union([z.instanceof(File), z.any()]).optional(), -}); +const submitSchema = z + .object({ + platform: z.enum(MiiPlatform).default("THREE_DS"), + name: nameSchema, + tags: tagsSchema, + description: z.string().trim().max(256).optional(), + + // Switch + gender: z.enum(MiiGender).default("MALE"), + miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + + // QR code + qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }), + + // Custom images + image1: z.union([z.instanceof(File), z.any()]).optional(), + image2: z.union([z.instanceof(File), z.any()]).optional(), + image3: z.union([z.instanceof(File), z.any()]).optional(), + }) + .refine( + (data) => { + // If platform is Switch, gender and miiPortraitImage must be present + if (data.platform === "SWITCH") { + return data.gender !== undefined && data.miiPortraitImage !== undefined; + } + return true; + }, + { + message: "Gender and Mii portrait image are required for Switch platform", + path: ["gender", "miiPortraitImage"], + } + ); export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 2); + const rateLimit = new RateLimit(request, 3); const check = await rateLimit.handle(); if (check) return check; const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const { value } = await response.json(); - if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409); + if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503); - // Parse data + // Parse tags and QR code as JSON const formData = await request.formData(); let rawTags: string[]; @@ -52,64 +75,85 @@ export async function POST(request: NextRequest) { rawTags = JSON.parse(formData.get("tags") as string); rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string); } catch { - return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400); + return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400); } + // Parse and check all submission info const parsed = submitSchema.safeParse({ + platform: formData.get("platform"), name: formData.get("name"), tags: rawTags, description: formData.get("description"), + + gender: formData.get("gender") ?? undefined, // ZOD MOMENT + miiPortraitImage: formData.get("miiPortraitImage"), + qrBytesRaw: rawQrBytesRaw, + image1: formData.get("image1"), image2: formData.get("image2"), image3: formData.get("image3"), }); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); - const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data; + const data = parsed.data; // Censor potential inappropriate words - const name = profanity.censor(uncensoredName); - const tags = uncensoredTags.map((t) => profanity.censor(t)); - const description = uncensoredDescription && profanity.censor(uncensoredDescription); + const name = profanity.censor(data.name); + const tags = data.tags.map((t) => profanity.censor(t)); + const description = data.description && profanity.censor(data.description); // Validate image files - const images: File[] = []; + const customImages: File[] = []; - for (const img of [image1, image2, image3]) { + for (const img of [data.image1, data.image2, data.image3]) { if (!img) continue; const imageValidation = await validateImage(img); if (imageValidation.valid) { - images.push(img); + customImages.push(img); } else { return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); } } - const qrBytes = new Uint8Array(qrBytesRaw); + // Check Mii portrait image as well (Switch) + if (data.platform === "SWITCH") { + const imageValidation = await validateImage(data.miiPortraitImage); + if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); + } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - return rateLimit.sendResponse({ error }, 400); + const qrBytes = new Uint8Array(data.qrBytesRaw); + + // Convert QR code to JS (3DS) + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined; + if (data.platform === "THREE_DS") { + try { + conversion = convertQrCode(qrBytes); + } catch (error) { + return rateLimit.sendResponse({ error }, 400); + } } // Create Mii in database const miiRecord = await prisma.mii.create({ data: { userId: Number(session.user.id), + platform: data.platform, name, tags, description, + gender: data.gender ?? "MALE", - firstName: conversion.tomodachiLifeMii.firstName, - lastName: conversion.tomodachiLifeMii.lastName, - gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, - islandName: conversion.tomodachiLifeMii.islandName, - allowedCopying: conversion.mii.allowCopying, + // Automatically detect certain information if on 3DS + ...(data.platform === "THREE_DS" && + conversion && { + firstName: conversion.tomodachiLifeMii.firstName, + lastName: conversion.tomodachiLifeMii.lastName, + gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, + islandName: conversion.tomodachiLifeMii.islandName, + allowedCopying: conversion.mii.allowCopying, + }), }, }); @@ -117,33 +161,37 @@ export async function POST(request: NextRequest) { const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); await fs.mkdir(miiUploadsDirectory, { recursive: true }); - // Download the image of the Mii - let studioBuffer: Buffer; try { - const studioUrl = conversion.mii.studioUrl({ width: 512 }); - const studioResponse = await fetch(studioUrl); + let portraitBuffer: Buffer | undefined; - if (!studioResponse.ok) { - throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + // Download the image of the Mii (3DS) + if (data.platform === "THREE_DS") { + const studioUrl = conversion?.mii.studioUrl({ width: 512 }); + const studioResponse = await fetch(studioUrl!); + + if (!studioResponse.ok) { + throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + } + + portraitBuffer = Buffer.from(await studioResponse.arrayBuffer()); + } else if (data.platform === "SWITCH") { + portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer()); } - const studioArrayBuffer = await studioResponse.arrayBuffer(); - studioBuffer = Buffer.from(studioArrayBuffer); + if (!portraitBuffer) throw Error("Mii portrait buffer not initialised"); + const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer(); + const fileLocation = path.join(miiUploadsDirectory, "mii.webp"); + + await fs.writeFile(fileLocation, webpBuffer); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Failed to download Mii image:", error); - return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); + console.error("Failed to download/store Mii portrait:", error); + return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); } try { - // Compress and store - const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); - const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); - - await fs.writeFile(studioFileLocation, studioWebpBuffer); - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); @@ -160,19 +208,25 @@ export async function POST(request: NextRequest) { const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); await fs.writeFile(codeFileLocation, codeWebpBuffer); - await generateMetadataImage(miiRecord, session.user.username!); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Error processing Mii files:", error); - return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); + console.error("Error generating QR code:", error); + return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500); + } + + try { + await generateMetadataImage(miiRecord, session.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500); } // Compress and store user images try { await Promise.all( - images.map(async (image, index) => { + customImages.map(async (image, index) => { const buffer = Buffer.from(await image.arrayBuffer()); const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); @@ -187,7 +241,7 @@ export async function POST(request: NextRequest) { id: miiRecord.id, }, data: { - imageCount: images.length, + imageCount: customImages.length, }, }); } catch (error) { diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index 5fa4526..9591a4a 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -66,13 +66,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (imageType === "metadata" && mii) { // Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`); - const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.username!); - if (error) { - return rateLimit.sendResponse({ error }, status); + try { + buffer = await generateMetadataImage(mii, mii.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); } - - buffer = metadataBuffer; } else { return rateLimit.sendResponse({ error: "Image not found" }, 404); } diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 5351772..8e7381b 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -109,13 +109,13 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Image */} -
+
{/* QR Code */} @@ -131,23 +131,25 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Info */} -
    -
  • - Name:{" "} - - {mii.firstName} {mii.lastName} - -
  • -
  • - From: {mii.islandName} Island -
  • -
  • - Allowed Copying: -
  • -
+ {mii.platform === "THREE_DS" && ( +
    +
  • + Name:{" "} + + {mii.firstName} {mii.lastName} + +
  • +
  • + From: {mii.islandName} Island +
  • +
  • + Allowed Copying: +
  • +
+ )} {/* Mii Gender */} -
+
("SWITCH"); + const [name, setName] = useState(""); + const [tags, setTags] = useState([]); + const [description, setDescription] = useState(""); + const [gender, setGender] = useState("MALE"); + const [qrBytesRaw, setQrBytesRaw] = useState([]); + + const [miiPortraitUri, setMiiPortraitUri] = useState(); + const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState(); + + const [error, setError] = useState(undefined); const [files, setFiles] = useState([]); const handleDrop = useCallback( @@ -34,17 +48,6 @@ export default function SubmitForm() { [files.length] ); - const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); - const [studioUrl, setStudioUrl] = useState(); - const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState(); - - const [error, setError] = useState(undefined); - - const [name, setName] = useState(""); - const [tags, setTags] = useState([]); - const [description, setDescription] = useState(""); - const [qrBytesRaw, setQrBytesRaw] = useState([]); - const handleSubmit = async () => { // Validate before sending request const nameValidation = nameSchema.safeParse(name); @@ -60,6 +63,7 @@ export default function SubmitForm() { // Send request to server const formData = new FormData(); + formData.append("platform", platform); formData.append("name", name); formData.append("tags", JSON.stringify(tags)); formData.append("description", description); @@ -69,6 +73,14 @@ export default function SubmitForm() { formData.append(`image${index + 1}`, file); }); + if (platform === "SWITCH") { + const response = await fetch(miiPortraitUri!); + const blob = await response.blob(); + + formData.append("gender", gender); + formData.append("miiPortraitImage", blob); + } + const response = await fetch("/api/submit", { method: "POST", body: formData, @@ -96,38 +108,41 @@ export default function SubmitForm() { return; } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - return; + // Convert QR code to JS (3DS) + if (platform === "THREE_DS") { + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; + try { + conversion = convertQrCode(qrBytes); + setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 })); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + return; + } } + // Generate a new QR code for aesthetic reasons try { - setStudioUrl(conversion.mii.studioUrl({ width: 512 })); - - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); generatedCode.addData(byteString, "Byte"); generatedCode.make(); - setGeneratedQrCodeUrl(generatedCode.createDataURL()); + setGeneratedQrCodeUri(generatedCode.createDataURL()); } catch { - setError("Failed to get and/or generate Mii images"); + setError("Failed to regenerate QR code"); } }; preview(); - }, [qrBytesRaw]); + }, [qrBytesRaw, platform]); return (
- URL.createObjectURL(file))]} /> + URL.createObjectURL(file))]} + />

@@ -162,6 +177,46 @@ export default function SubmitForm() {

+ {/* Platform select */} +
+ +
+ {/* Animated indicator */} +
+ + {/* Switch button */} + + + {/* 3DS button */} + +
+
+ + {/* Name */}
+ {/* Description */}
-