From e31141ea3949f84e8c0c5f952d2f0e7a716a5b99 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Fri, 27 Feb 2026 23:11:38 +0000 Subject: [PATCH] feat: fake mii editor --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/submit/route.ts | 128 ++++++--- src/app/globals.css | 33 +++ src/app/mii/[id]/page.tsx | 20 +- src/components/admin/control-center.tsx | 2 +- src/components/admin/return-to-island.tsx | 2 +- src/components/carousel.tsx | 26 +- src/components/mii-list/index.tsx | 2 +- src/components/mii-list/other-filters.tsx | 2 +- .../profile-settings/delete-account.tsx | 2 +- src/components/submit-form/edit-form.tsx | 2 +- src/components/submit-form/index.tsx | 239 +++++++++++------ .../submit-form/mii-editor/color-picker.tsx | 247 ++++++++++++++++++ .../submit-form/mii-editor/index.tsx | 82 ++++++ .../submit-form/mii-editor/number-inputs.tsx | 122 +++++++++ .../submit-form/mii-editor/tabs/ears.tsx | 39 +++ .../submit-form/mii-editor/tabs/eyebrows.tsx | 49 ++++ .../submit-form/mii-editor/tabs/eyes.tsx | 88 +++++++ .../submit-form/mii-editor/tabs/glasses.tsx | 56 ++++ .../submit-form/mii-editor/tabs/hair.tsx | 125 +++++++++ .../submit-form/mii-editor/tabs/head.tsx | 60 +++++ .../submit-form/mii-editor/tabs/lips.tsx | 48 ++++ .../submit-form/mii-editor/tabs/misc.tsx | 246 +++++++++++++++++ .../submit-form/mii-editor/tabs/nose.tsx | 39 +++ .../submit-form/mii-editor/tabs/other.tsx | 87 ++++++ .../submit-form/mii-editor/type-selector.tsx | 23 ++ src/components/tutorial/switch-submit.tsx | 46 +--- src/lib/images.tsx | 4 +- src/lib/qr-codes.ts | 6 +- src/lib/schemas.ts | 188 +++++++++++++ ...-mii.ts => three-ds-tomodachi-life-mii.ts} | 8 +- src/types.d.ts | 155 ++++++++++- 33 files changed, 1972 insertions(+), 207 deletions(-) create mode 100644 prisma/migrations/20260224165818_instructions_json/migration.sql create mode 100644 src/components/submit-form/mii-editor/color-picker.tsx create mode 100644 src/components/submit-form/mii-editor/index.tsx create mode 100644 src/components/submit-form/mii-editor/number-inputs.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/ears.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/eyebrows.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/eyes.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/glasses.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/hair.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/head.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/lips.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/misc.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/nose.tsx create mode 100644 src/components/submit-form/mii-editor/tabs/other.tsx create mode 100644 src/components/submit-form/mii-editor/type-selector.tsx rename src/lib/{tomodachi-life-mii.ts => three-ds-tomodachi-life-mii.ts} (93%) diff --git a/prisma/migrations/20260224165818_instructions_json/migration.sql b/prisma/migrations/20260224165818_instructions_json/migration.sql new file mode 100644 index 0000000..fe1125b --- /dev/null +++ b/prisma/migrations/20260224165818_instructions_json/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "miis" ADD COLUMN "instructions" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5a5c4b..83185a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model Mii { description String? @db.VarChar(256) platform MiiPlatform @default(THREE_DS) + instructions Json? firstName String? lastName String? gender MiiGender? diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index c7da211..b3f1ed0 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -12,13 +12,14 @@ import { MiiGender, MiiPlatform } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { nameSchema, tagsSchema } from "@/lib/schemas"; +import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas"; import { RateLimit } from "@/lib/rate-limit"; - import { generateMetadataImage, validateImage } from "@/lib/images"; import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; -import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; +import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii"; + +import { SwitchMiiInstructions } from "@/types"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); @@ -32,11 +33,15 @@ const submitSchema = z // Switch gender: z.enum(MiiGender).default("MALE"), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + instructions: switchMiiInstructionsSchema, // 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", - }), + 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", + }) + .nullish(), // Custom images image1: z.union([z.instanceof(File), z.any()]).optional(), @@ -53,8 +58,8 @@ const submitSchema = z return true; }, { - message: "Gender and Mii portrait image are required for Switch platform", - path: ["gender", "miiPortraitImage"], + message: "Gender, Mii portrait image, and instructions are required for Switch platform", + path: ["gender", "miiPortraitImage", "instructions"], }, ); @@ -95,6 +100,7 @@ export async function POST(request: NextRequest) { gender: formData.get("gender") ?? undefined, // ZOD MOMENT miiPortraitImage: formData.get("miiPortraitImage"), + instructions: JSON.parse((formData.get("instructions") as string) ?? {}), qrBytesRaw: rawQrBytesRaw, @@ -103,7 +109,19 @@ export async function POST(request: NextRequest) { image3: formData.get("image3"), }); - if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); + if (!parsed.success) { + const error = parsed.error.issues[0].message; + const issues = parsed.error.issues; + const hasInstructionsErrors = issues.some((issue) => issue.path[0] === "instructions"); + + if (hasInstructionsErrors) { + Sentry.captureException(error, { + extra: { issues, rawInstructions: formData.get("instructions"), stage: "submit-instructions" }, + }); + } + + return rateLimit.sendResponse({ error }, 400); + } const { platform, name: uncensoredName, @@ -112,6 +130,7 @@ export async function POST(request: NextRequest) { qrBytesRaw, gender, miiPortraitImage, + instructions, image1, image2, image3, @@ -137,15 +156,41 @@ export async function POST(request: NextRequest) { } // Check Mii portrait image as well (Switch) + let minifiedInstructions: Partial; if (platform === "SWITCH") { const imageValidation = await validateImage(miiPortraitImage); if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); + + // Minimize instructions to save space and improve user experience + function minimize(object: Partial): Partial { + for (const key in object) { + const value = object[key as keyof SwitchMiiInstructions]; + + if (!value) { + delete object[key as keyof SwitchMiiInstructions]; + continue; + } + + // Recurse into nested objects + if (typeof value === "object") { + minimize(value as Partial); + + if (Object.keys(value).length === 0) { + delete object[key as keyof SwitchMiiInstructions]; + } + } + } + + return object; + } + + minifiedInstructions = minimize(instructions as SwitchMiiInstructions); } - const qrBytes = new Uint8Array(qrBytesRaw); + const qrBytes = new Uint8Array(qrBytesRaw ?? []); // Convert QR code to JS (3DS) - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined; + let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined; if (platform === "THREE_DS") { try { conversion = convertQrCode(qrBytes); @@ -166,14 +211,17 @@ export async function POST(request: NextRequest) { gender: gender ?? "MALE", // Automatically detect certain information if on 3DS - ...(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, - }), + ...(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, + } + : { + instructions, + }), }, }); @@ -212,30 +260,32 @@ export async function POST(request: NextRequest) { return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); } - try { - // Generate a new QR code for aesthetic reasons - const byteString = String.fromCharCode(...qrBytes); - const generatedCode = qrcode(0, "L"); - generatedCode.addData(byteString, "Byte"); - generatedCode.make(); + if (platform === "THREE_DS") { + try { + // Generate a new QR code for aesthetic reasons + const byteString = String.fromCharCode(...qrBytes); + const generatedCode = qrcode(0, "L"); + generatedCode.addData(byteString, "Byte"); + generatedCode.make(); - // Store QR code - const codeDataUrl = generatedCode.createDataURL(); - const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); - const codeBuffer = Buffer.from(codeBase64, "base64"); + // Store QR code + const codeDataUrl = generatedCode.createDataURL(); + const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); + const codeBuffer = Buffer.from(codeBase64, "base64"); - // Compress and store - const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); - const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); + // Compress and store + const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); + const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); - await fs.writeFile(codeFileLocation, codeWebpBuffer); - } catch (error) { - // Clean up if something went wrong - await prisma.mii.delete({ where: { id: miiRecord.id } }); + await fs.writeFile(codeFileLocation, codeWebpBuffer); + } catch (error) { + // Clean up if something went wrong + await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Error processing Mii files:", error); - Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } }); - return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); + console.error("Error processing Mii files:", error); + Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } }); + return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); + } } try { diff --git a/src/app/globals.css b/src/app/globals.css index 45971bd..e551000 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -118,3 +118,36 @@ body { *::-webkit-scrollbar-track { background: #ff8903; } + +/* Range input */ +input[type="range"] { + @apply appearance-none bg-transparent cursor-pointer; +} + +/* Track */ +input[type="range"]::-webkit-slider-runnable-track { + @apply h-2 bg-orange-200 border-2 border-orange-400 rounded-full; +} + +input[type="range"]::-moz-range-track { + @apply h-1 bg-orange-200 border-2 border-orange-400 rounded-full; +} + +/* Thumb */ +input[type="range"]::-webkit-slider-thumb { + @apply appearance-none size-4 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition; + margin-top: -6px; /* center thumb vertically */ +} + +input[type="range"]::-moz-range-thumb { + @apply size-3.5 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition; +} + +/* Hover */ +input[type="range"]:hover::-webkit-slider-thumb { + @apply bg-orange-500; +} + +input[type="range"]:hover::-moz-range-thumb { + @apply bg-orange-500; +} diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 0b7d8b2..8bee268 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -132,15 +132,17 @@ export default async function MiiPage({ params }: Props) { /> {/* QR Code */} -
- -
+ {mii.platform === "THREE_DS" && ( +
+ +
+ )}
{/* Mii Info */} diff --git a/src/components/admin/control-center.tsx b/src/components/admin/control-center.tsx index f6ee242..b478d28 100644 --- a/src/components/admin/control-center.tsx +++ b/src/components/admin/control-center.tsx @@ -24,7 +24,7 @@ export default function ControlCenter() {
setIsChecked(e.target.checked)} diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index 8bbd5be..8fd84a9 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -19,7 +19,9 @@ export default function Carousel({ images, className }: Props) { useEffect(() => { if (!emblaApi) return; + emblaApi.reInit(); setScrollSnaps(emblaApi.scrollSnapList()); + setSelectedIndex(0); emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); }, [images, emblaApi]); @@ -74,20 +76,20 @@ export default function Carousel({ images, className }: Props) { > - -
- {scrollSnaps.map((_, index) => ( -
)} + +
+ {scrollSnaps.map((_, index) => ( +
); } diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 3e3edba..c1dc2e6 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/mii-list/other-filters.tsx b/src/components/mii-list/other-filters.tsx index 8a2a346..4308e38 100644 --- a/src/components/mii-list/other-filters.tsx +++ b/src/components/mii-list/other-filters.tsx @@ -32,7 +32,7 @@ export default function OtherFilters() { - +
); } diff --git a/src/components/profile-settings/delete-account.tsx b/src/components/profile-settings/delete-account.tsx index 8ea8ef2..f4911f5 100644 --- a/src/components/profile-settings/delete-account.tsx +++ b/src/components/profile-settings/delete-account.tsx @@ -39,7 +39,7 @@ export default function DeleteAccount() { return ( <> - diff --git a/src/components/submit-form/edit-form.tsx b/src/components/submit-form/edit-form.tsx index f27b3f4..5d3840a 100644 --- a/src/components/submit-form/edit-form.tsx +++ b/src/components/submit-form/edit-form.tsx @@ -147,7 +147,7 @@ export default function EditForm({ mii, likes }: Props) { Name ("SWITCH"); const [gender, setGender] = useState("MALE"); + const instructions = useRef({ + head: { type: 0, skinColor: 0 }, + hair: { + setType: 0, + bangsType: 0, + backType: 0, + color: 0, + subColor: 0, + style: 0, + isFlipped: false, + }, + eyebrows: { type: 0, color: 0, height: 0, distance: 0, rotation: 0, size: 0, stretch: 0 }, + eyes: { + eyesType: 0, + eyelashesTop: 0, + eyelashesBottom: 0, + eyelidTop: 0, + eyelidBottom: 0, + eyeliner: 0, + pupil: 0, + color: 0, + height: 0, + distance: 0, + rotation: 0, + size: 0, + stretch: 0, + }, + nose: { type: 0, height: 0, size: 0 }, + lips: { type: 0, color: 0, height: 0, rotation: 0, size: 0, stretch: 0, hasLipstick: false }, + ears: { type: 0, height: 0, size: 0 }, + glasses: { type: 0, ringColor: 0, shadesColor: 0, height: 0, size: 0, stretch: 0 }, + other: { + wrinkles1: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + wrinkles2: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + beard: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + moustache: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + goatee: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + mole: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + eyeShadow: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + blush: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, + }, + height: 0, + weight: 0, + datingPreferences: [], + voice: { speed: 0, pitch: 0, depth: 0, delivery: 0, tone: 0 }, + personality: { movement: 0, speech: 0, energy: 0, thinking: 0, overall: 0 }, + }); const [error, setError] = useState(undefined); @@ -70,13 +119,14 @@ export default function SubmitForm() { formData.append("name", name); formData.append("tags", JSON.stringify(tags)); formData.append("description", description); - formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); files.forEach((file, index) => { // image1, image2, etc. formData.append(`image${index + 1}`, file); }); - if (platform === "SWITCH") { + if (platform === "THREE_DS") { + formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); + } else if (platform === "SWITCH") { const response = await fetch(miiPortraitUri!); if (!response.ok) { @@ -92,6 +142,7 @@ export default function SubmitForm() { formData.append("gender", gender); formData.append("miiPortraitImage", blob); + formData.append("instructions", JSON.stringify(instructions.current)); } const response = await fetch("/api/submit", { @@ -109,7 +160,7 @@ export default function SubmitForm() { }; useEffect(() => { - if (qrBytesRaw.length == 0) return; + if (platform === "SWITCH" || qrBytesRaw.length == 0) return; const qrBytes = new Uint8Array(qrBytesRaw); const preview = async () => { @@ -123,7 +174,7 @@ export default function SubmitForm() { // Convert QR code to JS (3DS) if (platform === "THREE_DS") { - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; + let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii }; try { conversion = convertQrCode(qrBytes); setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 })); @@ -153,7 +204,13 @@ export default function SubmitForm() {
- URL.createObjectURL(file))]} /> + URL.createObjectURL(file)), + ]} + />

@@ -234,7 +291,7 @@ export default function SubmitForm() { Name