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/migrations/20260224165250_nonbinary_gender/migration.sql b/prisma/migrations/20260224165250_nonbinary_gender/migration.sql new file mode 100644 index 0000000..46a344c --- /dev/null +++ b/prisma/migrations/20260224165250_nonbinary_gender/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "MiiGender" ADD VALUE 'NONBINARY'; 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 7c029bf..83185a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,17 +69,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) + + instructions Json? + firstName String? + lastName String? gender MiiGender? - islandName String + islandName String? allowedCopying Boolean? createdAt DateTime @default(now()) @@ -154,9 +157,15 @@ model Punishment { @@map("punishments") } +enum MiiPlatform { + SWITCH + THREE_DS // can't start with a number +} + enum MiiGender { MALE FEMALE + NONBINARY } enum ReportType { diff --git a/public/tutorial/adding-mii/step2.png b/public/tutorial/adding-mii/step2.png deleted file mode 100644 index 60a1eb9..0000000 Binary files a/public/tutorial/adding-mii/step2.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step3.png b/public/tutorial/adding-mii/step3.png deleted file mode 100644 index df0840c..0000000 Binary files a/public/tutorial/adding-mii/step3.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step4.png b/public/tutorial/adding-mii/step4.png deleted file mode 100644 index 2f21ed9..0000000 Binary files a/public/tutorial/adding-mii/step4.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step5.png b/public/tutorial/adding-mii/step5.png deleted file mode 100644 index 9269d13..0000000 Binary files a/public/tutorial/adding-mii/step5.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step2.png b/public/tutorial/allow-copying/step2.png deleted file mode 100644 index 9c2b295..0000000 Binary files a/public/tutorial/allow-copying/step2.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step3.png b/public/tutorial/allow-copying/step3.png deleted file mode 100644 index 307b5f6..0000000 Binary files a/public/tutorial/allow-copying/step3.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step4.png b/public/tutorial/allow-copying/step4.png deleted file mode 100644 index f91cc5f..0000000 Binary files a/public/tutorial/allow-copying/step4.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step5.png b/public/tutorial/allow-copying/step5.png deleted file mode 100644 index 93b0cf5..0000000 Binary files a/public/tutorial/allow-copying/step5.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step6.png b/public/tutorial/allow-copying/step6.png deleted file mode 100644 index 67bae80..0000000 Binary files a/public/tutorial/allow-copying/step6.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step7.png b/public/tutorial/allow-copying/step7.png deleted file mode 100644 index b30964d..0000000 Binary files a/public/tutorial/allow-copying/step7.png and /dev/null differ diff --git a/public/tutorial/allow-copying/thumbnail.png b/public/tutorial/allow-copying/thumbnail.png deleted file mode 100644 index ab54c2a..0000000 Binary files a/public/tutorial/allow-copying/thumbnail.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step2.png b/public/tutorial/create-qr-code/step2.png deleted file mode 100644 index 60a1eb9..0000000 Binary files a/public/tutorial/create-qr-code/step2.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step3.png b/public/tutorial/create-qr-code/step3.png deleted file mode 100644 index dbc6743..0000000 Binary files a/public/tutorial/create-qr-code/step3.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step4.png b/public/tutorial/create-qr-code/step4.png deleted file mode 100644 index 562b19f..0000000 Binary files a/public/tutorial/create-qr-code/step4.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step5.png b/public/tutorial/create-qr-code/step5.png deleted file mode 100644 index e6cf9fd..0000000 Binary files a/public/tutorial/create-qr-code/step5.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step6.png b/public/tutorial/create-qr-code/step6.png deleted file mode 100644 index f78da9a..0000000 Binary files a/public/tutorial/create-qr-code/step6.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/thumbnail.png b/public/tutorial/create-qr-code/thumbnail.png deleted file mode 100644 index cfb7906..0000000 Binary files a/public/tutorial/create-qr-code/thumbnail.png and /dev/null differ diff --git a/public/tutorial/step1.png b/public/tutorial/step1.png deleted file mode 100644 index 13983f9..0000000 Binary files a/public/tutorial/step1.png and /dev/null differ diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 85b40ac..87b0e89 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -139,7 +139,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(updatedMii, updatedMii.user.name!); + try { + await generateMetadataImage(updatedMii, updatedMii.user.name!); + } 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 824d51f..a68783f 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -8,46 +8,75 @@ 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"; -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"); -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(), + 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", + }) + .nullish(), + + // 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(), + }) + // This refine function is probably useless + .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, Mii portrait image, and instructions are required for Switch platform", + path: ["gender", "miiPortraitImage", "instructions"], + }, + ); export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); Sentry.setUser({ id: session.user.id, username: session.user.username }); - 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[]; @@ -62,18 +91,78 @@ export async function POST(request: NextRequest) { return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400); } + // Minify instructions to save space and improve user experience + let minifiedInstructions: Partial | undefined; + if (formData.get("platform") === "SWITCH") { + function minify(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") { + minify(value as Partial); + + if (Object.keys(value).length === 0) { + delete object[key as keyof SwitchMiiInstructions]; + } + } + } + + return object; + } + + minifiedInstructions = minify(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions); + } + + // 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"), + instructions: minifiedInstructions, + 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; + 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, + tags: uncensoredTags, + description: uncensoredDescription, + qrBytesRaw, + gender, + miiPortraitImage, + instructions, + image1, + image2, + image3, + } = parsed.data; // Censor potential inappropriate words const name = profanity.censor(uncensoredName); @@ -81,43 +170,60 @@ export async function POST(request: NextRequest) { const description = uncensoredDescription && profanity.censor(uncensoredDescription); // Validate image files - const images: File[] = []; + const customImages: File[] = []; for (const img of [image1, image2, 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 (platform === "SWITCH") { + const imageValidation = await validateImage(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) { - Sentry.captureException(error, { extra: { stage: "qr-conversion" } }); - return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + const qrBytes = new Uint8Array(qrBytesRaw ?? []); + + // Convert QR code to JS (3DS) + let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined; + if (platform === "THREE_DS") { + try { + conversion = convertQrCode(qrBytes); + } catch (error) { + Sentry.captureException(error, { extra: { stage: "qr-conversion" } }); + return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } // Create Mii in database const miiRecord = await prisma.mii.create({ data: { userId: Number(session.user.id), + platform, name, tags, description, + gender: 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 + ...(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: minifiedInstructions, + }), }, }); @@ -125,64 +231,77 @@ 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 (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 (platform === "SWITCH") { + portraitBuffer = Buffer.from(await 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); + console.error("Failed to download/store Mii portrait:", error); Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } }); - return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); + return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); + } + + 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"); + + // 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 } }); + + 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 { - // 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"); - 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"); - - // 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); await generateMetadataImage(miiRecord, session.user.name!); } 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); + Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "metadata-image" } }); + 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`); @@ -197,7 +316,7 @@ export async function POST(request: NextRequest) { id: miiRecord.id, }, data: { - imageCount: images.length, + imageCount: customImages.length, }, }); } catch (error) { diff --git a/src/app/globals.css b/src/app/globals.css index 6131603..add78f3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -91,6 +91,23 @@ body { @apply opacity-100 scale-100; } +/* Fallback Tooltips */ +[data-tooltip-span] { + @apply relative; +} + +[data-tooltip-span] > .tooltip { + @apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999; +} + +[data-tooltip-span] > .tooltip::before { + @apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400; +} + +[data-tooltip-span]:hover > .tooltip { + @apply opacity-100 scale-100; +} + /* Scrollbar */ /* Firefox */ * { @@ -101,3 +118,35 @@ body { *::-webkit-scrollbar-track { background: #ff8903; } + +/* Range input */ +input[type="range"] { + @apply appearance-none bg-transparent not-disabled: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 -mt-1.5; +} + +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 not-disabled:bg-orange-500; +} + +input[type="range"]:hover::-moz-range-thumb { + @apply not-disabled:bg-orange-500; +} diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 16a574e..ea5be47 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -7,14 +7,18 @@ import { Icon } from "@iconify/react"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { MiiPlatform } from "@prisma/client"; import LikeButton from "@/components/like-button"; import ImageViewer from "@/components/image-viewer"; -import DeleteMiiButton from "@/components/delete-mii"; -import ShareMiiButton from "@/components/share-mii-button"; -import ScanTutorialButton from "@/components/tutorial/scan"; -import ProfilePicture from "@/components/profile-picture"; +import DeleteMiiButton from "@/components/mii/delete-mii-button"; +import ShareMiiButton from "@/components/mii/share-mii-button"; +import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan"; +import SwitchScanTutorialButton from "@/components/tutorial/switch-scan"; import Description from "@/components/description"; +import MiiInstructions from "@/components/mii/instructions"; + +import { SwitchMiiInstructions } from "@/types"; interface Props { params: Promise<{ id: string }>; @@ -30,6 +34,7 @@ export async function generateMetadata({ params }: Props): Promise { include: { user: { select: { + name: true, username: true, }, }, @@ -44,28 +49,36 @@ export async function generateMetadata({ params }: Props): Promise { const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`; - const username = `@${mii.user.username}`; - return { metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags], - creator: username, + creator: mii.user.username, openGraph: { type: "article", title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, + images: [ + { + url: metadataImageUrl, + alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, + }, + ], publishedTime: mii.createdAt.toISOString(), - authors: username, + authors: mii.user.username, }, twitter: { card: "summary_large_image", title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], - creator: username, + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, + images: [ + { + url: metadataImageUrl, + alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, + }, + ], + creator: mii.user.username!, }, alternates: { canonical: `/mii/${mii.id}`, @@ -110,51 +123,104 @@ export default async function MiiPage({ params }: Props) {
-
+
{/* Mii Image */}
{/* QR Code */} -
- -
+ {mii.platform === "THREE_DS" && ( +
+ +
+ )}
{/* Mii Info */} -
    -
  • - Name:{" "} - - {mii.firstName} {mii.lastName} - -
  • -
  • - From: {mii.islandName} Island -
  • - {mii.allowedCopying !== null && ( + {mii.platform === "THREE_DS" && ( +
    • - Allowed Copying: + Name:{" "} + + {mii.firstName} {mii.lastName} +
    • - )} -
    +
  • + From: {mii.islandName} Island +
  • +
  • + Allowed Copying: +
  • +
+ )} + + {/* Mii Platform */} +
+
+ Platform +
+
+ +
+
+ {mii.platform === "THREE_DS" ? "3DS" : "Switch"} +
+ +
+ +
+ +
+ +
+
{/* Mii Gender */} -
+
+
+ Gender +
+
+ +
+ {mii.gender === "MALE" ? "Male" : mii.gender === "FEMALE" ? "Female" : "Nonbinary"} +
+ +
@@ -162,12 +228,22 @@ export default async function MiiPage({ params }: Props) {
+ + {mii.platform !== "THREE_DS" && ( +
+ +
+ )}
@@ -230,8 +306,11 @@ export default async function MiiPage({ params }: Props) { Report - + {mii.platform === "THREE_DS" ? : }
+ + {/* Instructions */} + {mii.platform === "SWITCH" && } />}
@@ -269,7 +348,7 @@ export default async function MiiPage({ params }: Props) { ))}
) : ( -

There is nothing here...

+

There is nothing here...

)}
diff --git a/src/app/off-the-island/page.tsx b/src/app/off-the-island/page.tsx index 3256eb4..d44f77a 100644 --- a/src/app/off-the-island/page.tsx +++ b/src/app/off-the-island/page.tsx @@ -95,9 +95,7 @@ export default async function ExiledPage() {
mii image
-

- {mii.mii.name} -

+

{mii.mii.name}

Reason: {mii.reason}

diff --git a/src/app/page.tsx b/src/app/page.tsx index 801e5e7..40482a7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,8 +7,8 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import Countdown from "@/components/countdown"; -import MiiList from "@/components/mii-list"; -import Skeleton from "@/components/mii-list/skeleton"; +import MiiList from "@/components/mii/list"; +import Skeleton from "@/components/mii/list/skeleton"; interface Props { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 422d218..88dd476 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -5,8 +5,8 @@ import { Suspense } from "react"; import { prisma } from "@/lib/prisma"; import ProfileInformation from "@/components/profile-information"; -import MiiList from "@/components/mii-list"; -import Skeleton from "@/components/mii-list/skeleton"; +import MiiList from "@/components/mii/list"; +import Skeleton from "@/components/mii/list/skeleton"; interface Props { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/profile/likes/page.tsx b/src/app/profile/likes/page.tsx index abce75e..4d84cf6 100644 --- a/src/app/profile/likes/page.tsx +++ b/src/app/profile/likes/page.tsx @@ -5,8 +5,8 @@ import { Suspense } from "react"; import { auth } from "@/lib/auth"; import ProfileInformation from "@/components/profile-information"; -import Skeleton from "@/components/mii-list/skeleton"; -import MiiList from "@/components/mii-list"; +import Skeleton from "@/components/mii/list/skeleton"; +import MiiList from "@/components/mii/list"; interface Props { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 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 d8c379c..8fd84a9 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -12,14 +12,16 @@ interface Props { } export default function Carousel({ images, className }: Props) { - const [emblaRef, emblaApi] = useEmblaCarousel(); + const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 }); const [selectedIndex, setSelectedIndex] = useState(0); const [scrollSnaps, setScrollSnaps] = useState([]); const [isFocused, setIsFocused] = useState(false); 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/dating-preferences.tsx b/src/components/mii/dating-preferences.tsx new file mode 100644 index 0000000..17844a9 --- /dev/null +++ b/src/components/mii/dating-preferences.tsx @@ -0,0 +1,38 @@ +import { ChangeEvent } from "react"; +import { MiiGender } from "@prisma/client"; +import { SwitchMiiInstructions } from "@/types"; + +interface Props { + data: SwitchMiiInstructions["datingPreferences"]; + onChecked?: (e: ChangeEvent, gender: MiiGender) => void; +} + +const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"]; + +export default function DatingPreferencesViewer({ data, onChecked }: Props) { + return ( +
+ {DATING_PREFERENCES.map((gender) => { + const genderEnum = gender.toUpperCase() as MiiGender; + + return ( +
+ { + if (onChecked) onChecked(e, genderEnum); + }} + /> + +
+ ); + })} +
+ ); +} diff --git a/src/components/delete-mii.tsx b/src/components/mii/delete-mii-button.tsx similarity index 97% rename from src/components/delete-mii.tsx rename to src/components/mii/delete-mii-button.tsx index 8fe58e8..e5c4a0c 100644 --- a/src/components/delete-mii.tsx +++ b/src/components/mii/delete-mii-button.tsx @@ -6,8 +6,8 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Icon } from "@iconify/react"; -import LikeButton from "./like-button"; -import SubmitButton from "./submit-button"; +import LikeButton from "../like-button"; +import SubmitButton from "../submit-button"; interface Props { miiId: number; diff --git a/src/components/mii/instructions.tsx b/src/components/mii/instructions.tsx new file mode 100644 index 0000000..716f9b9 --- /dev/null +++ b/src/components/mii/instructions.tsx @@ -0,0 +1,272 @@ +import React from "react"; + +import DatingPreferencesViewer from "./dating-preferences"; +import VoiceViewer from "./voice-viewer"; +import PersonalityViewer from "./personality-viewer"; + +import { SwitchMiiInstructions } from "@/types"; +import { Icon } from "@iconify/react"; +import { COLORS } from "@/lib/switch"; + +interface Props { + instructions: Partial; +} + +interface SectionProps { + name: string; + instructions: Partial; + children?: React.ReactNode; + isSubSection?: boolean; +} + +const ORDINAL_SUFFIXES: Record = { + one: "st", + two: "nd", + few: "rd", + other: "th", +}; +const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); + +function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) { + const row = Math.floor(index / cols) + 1; + const col = (index % cols) + 1; + const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)]; + const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)]; + + return `${row}${rowSuffix} row, ${col}${colSuffix} column`; +} + +function ColorPosition({ color }: { color: number }) { + if (!color) return null; + if (color <= 7) { + return ( + <> + Color menu on left, + + ); + } + if (color >= 108) { + return ( + <> + Outside color menu, + + ); + } + + return ( + +
+ Color menu on right, +
+ ); +} + +interface TableCellProps { + label: string; + children: React.ReactNode; +} + +function TableCell({ label, children }: TableCellProps) { + return ( + + {label} + {children} + + ); +} + +function Section({ name, instructions, children, isSubSection }: SectionProps) { + if (typeof instructions !== "object") return null; + + const type = "type" in instructions ? instructions.type : undefined; + const color = "color" in instructions ? instructions.color : undefined; + const height = "height" in instructions ? instructions.height : undefined; + const distance = "distance" in instructions ? instructions.distance : undefined; + const rotation = "rotation" in instructions ? instructions.rotation : undefined; + const size = "size" in instructions ? instructions.size : undefined; + const stretch = "stretch" in instructions ? instructions.stretch : undefined; + + return ( +
+

{name}

+ + + + {type && ( + + + + )} + {color && ( + + + + )} + {height && {height}} + {distance && {distance}} + {rotation && {rotation}} + {size && {size}} + {stretch && {stretch}} + + {children} + +
+
+ ); +} + +export default function MiiInstructions({ instructions }: Props) { + if (Object.keys(instructions).length === 0) return null; + const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, datingPreferences, voice, personality } = instructions; + + return ( +
+

+ + Instructions +

+ + {head &&
} + {hair && ( +
+ {hair.setType && ( + + + + )} + {hair.bangsType && ( + + + + )} + {hair.backType && ( + + + + )} + {hair.subColor && ( + + + + )} +
+ )} + {eyebrows &&
} + {eyes && ( +
+ {eyes.eyesType && ( + + + + )} + {eyes.eyelashesTop && ( + + + + )} + {eyes.eyelashesBottom && ( + + + + )} + {eyes.eyelidTop && ( + + + + )} + {eyes.eyelidBottom && ( + + + + )} + {eyes.eyeliner && ( + + + + )} + {eyes.pupil && ( + + + + )} +
+ )} + {nose &&
} + {lips &&
} + {ears &&
} + {glasses && ( +
+ {glasses.ringColor && ( + + + + )} + {glasses.shadesColor && ( + + + + )} +
+ )} + {other && ( +
+
+
+
+
+
+
+
+
+
+ )} + + {(height || weight || datingPreferences || voice || personality) && ( +
+

Misc

+ + {height && ( +
+ + +
+ )} + {weight && ( +
+ + +
+ )} + {datingPreferences && ( +
+

Dating Preferences

+
+ +
+
+ )} + {voice && ( +
+

Voice

+
+ +
+
+ )} + {personality && ( +
+

Personality

+
+ +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/mii-list/filter-menu.tsx b/src/components/mii/list/filter-menu.tsx similarity index 82% rename from src/components/mii-list/filter-menu.tsx rename to src/components/mii/list/filter-menu.tsx index 848e607..e1cbe69 100644 --- a/src/components/mii-list/filter-menu.tsx +++ b/src/components/mii/list/filter-menu.tsx @@ -4,8 +4,9 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { Icon } from "@iconify/react"; -import { MiiGender } from "@prisma/client"; +import { MiiGender, MiiPlatform } from "@prisma/client"; +import PlatformSelect from "./platform-select"; import TagFilter from "./tag-filter"; import GenderSelect from "./gender-select"; import OtherFilters from "./other-filters"; @@ -16,9 +17,10 @@ export default function FilterMenu() { const [isOpen, setIsOpen] = useState(false); const [isVisible, setIsVisible] = useState(false); + const platform = (searchParams.get("platform") as MiiPlatform) || undefined; + const gender = (searchParams.get("gender") as MiiGender) || undefined; const rawTags = searchParams.get("tags") || ""; const rawExclude = searchParams.get("exclude") || ""; - const gender = (searchParams.get("gender") as MiiGender) || undefined; const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false; const tags = useMemo( @@ -61,11 +63,12 @@ export default function FilterMenu() { // Count all active filters useEffect(() => { let count = tags.length + exclude.length; + if (platform) count++; if (gender) count++; if (allowCopying) count++; setFilterCount(count); - }, [tags, exclude, gender, allowCopying]); + }, [tags, exclude, platform, gender, allowCopying]); return (
@@ -84,6 +87,20 @@ export default function FilterMenu() {
+
+ Platform +
+
+ + +
+
+ Gender +
+
+ + +

Tags Include
@@ -97,19 +114,16 @@ export default function FilterMenu() {
-
-
- Gender -
-
- - -
-
- Other -
-
- + {platform !== "SWITCH" && ( + <> +
+
+ Other +
+
+ + + )}
)}
diff --git a/src/components/mii-list/gender-select.tsx b/src/components/mii/list/gender-select.tsx similarity index 58% rename from src/components/mii-list/gender-select.tsx rename to src/components/mii/list/gender-select.tsx index 2a9c0d3..936b213 100644 --- a/src/components/mii-list/gender-select.tsx +++ b/src/components/mii/list/gender-select.tsx @@ -3,7 +3,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; import { Icon } from "@iconify/react"; -import { MiiGender } from "@prisma/client"; +import { MiiGender, MiiPlatform } from "@prisma/client"; export default function GenderSelect() { const router = useRouter(); @@ -11,6 +11,7 @@ export default function GenderSelect() { const [, startTransition] = useTransition(); const [selected, setSelected] = useState((searchParams.get("gender") as MiiGender) ?? null); + const platform = (searchParams.get("platform") as MiiPlatform) || undefined; const handleClick = (gender: MiiGender) => { const filter = selected === gender ? null : gender; @@ -31,26 +32,44 @@ export default function GenderSelect() { }; return ( -
+
+ + {platform !== "THREE_DS" && ( + + )}
); } diff --git a/src/components/mii-list/index.tsx b/src/components/mii/list/index.tsx similarity index 91% rename from src/components/mii-list/index.tsx rename to src/components/mii/list/index.tsx index 1e7f20e..1921b64 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii/list/index.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { Prisma } from "@prisma/client"; +import { MiiGender, MiiPlatform, Prisma } from "@prisma/client"; import { Icon } from "@iconify/react"; import crypto from "crypto"; @@ -11,9 +11,9 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import SortSelect from "./sort-select"; -import Carousel from "../carousel"; -import LikeButton from "../like-button"; -import DeleteMiiButton from "../delete-mii"; +import Carousel from "../../carousel"; +import LikeButton from "../../like-button"; +import DeleteMiiButton from "../delete-mii-button"; import Pagination from "./pagination"; import FilterMenu from "./filter-menu"; @@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro const parsed = searchSchema.safeParse(searchParams); if (!parsed.success) return

{parsed.error.issues[0].message}

; - const { q: query, sort, tags, exclude, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; + const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; // My Likes page let miiIdsLiked: number[] | undefined = undefined; @@ -52,6 +52,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro // Tag filtering ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), + // Platform + ...(platform && { platform: { equals: platform } }), // Gender ...(gender && { gender: { equals: gender } }), // Allow Copying @@ -71,6 +73,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro }, }, }), + platform: true, name: true, imageCount: true, tags: true, @@ -143,7 +146,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro [totalCount, filteredCount, list] = await Promise.all([ prisma.mii.count({ where: { ...where, userId } }), prisma.mii.count({ where, skip, take: limit }), - prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), + prisma.mii.findMany({ + where, + orderBy, + select, + skip: (page - 1) * limit, + take: limit, + }), ]); } @@ -156,7 +165,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro return (
-
+
{totalCount == filteredCount ? ( <> @@ -188,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 similarity index 88% rename from src/components/mii-list/other-filters.tsx rename to 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/mii-list/pagination.tsx b/src/components/mii/list/pagination.tsx similarity index 100% rename from src/components/mii-list/pagination.tsx rename to src/components/mii/list/pagination.tsx diff --git a/src/components/mii/list/platform-select.tsx b/src/components/mii/list/platform-select.tsx new file mode 100644 index 0000000..0327c62 --- /dev/null +++ b/src/components/mii/list/platform-select.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Icon } from "@iconify/react"; +import { MiiPlatform } from "@prisma/client"; + +export default function PlatformSelect() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const [selected, setSelected] = useState((searchParams.get("platform") as MiiPlatform) ?? null); + + const handleClick = (platform: MiiPlatform) => { + const filter = selected === platform ? null : platform; + setSelected(filter); + + const params = new URLSearchParams(searchParams); + if (filter) { + params.set("platform", filter); + } else { + params.delete("platform"); + } + + startTransition(() => { + router.push(`?${params.toString()}`); + }); + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/mii-list/skeleton.tsx b/src/components/mii/list/skeleton.tsx similarity index 100% rename from src/components/mii-list/skeleton.tsx rename to src/components/mii/list/skeleton.tsx diff --git a/src/components/mii-list/sort-select.tsx b/src/components/mii/list/sort-select.tsx similarity index 100% rename from src/components/mii-list/sort-select.tsx rename to src/components/mii/list/sort-select.tsx diff --git a/src/components/mii-list/tag-filter.tsx b/src/components/mii/list/tag-filter.tsx similarity index 97% rename from src/components/mii-list/tag-filter.tsx rename to src/components/mii/list/tag-filter.tsx index 4cbbb8c..ad4b0d3 100644 --- a/src/components/mii-list/tag-filter.tsx +++ b/src/components/mii/list/tag-filter.tsx @@ -2,7 +2,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; -import TagSelector from "../tag-selector"; +import TagSelector from "../../tag-selector"; interface Props { isExclude?: boolean; diff --git a/src/components/mii/personality-viewer.tsx b/src/components/mii/personality-viewer.tsx new file mode 100644 index 0000000..35eab7c --- /dev/null +++ b/src/components/mii/personality-viewer.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { SwitchMiiInstructions } from "@/types"; + +interface Props { + data: SwitchMiiInstructions["personality"]; + onClick?: (key: string, i: number) => void; +} + +const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [ + { label: "Movement", left: "Slow", right: "Quick" }, + { label: "Speech", left: "Polite", right: "Honest" }, + { label: "Energy", left: "Flat", right: "Varied" }, + { label: "Thinking", left: "Serious", right: "Chill" }, + { label: "Overall", left: "Normal", right: "Quirky" }, +]; + +export default function PersonalityViewer({ data, onClick }: Props) { + return ( +
+ {PERSONALITY_SETTINGS.map(({ label, left, right }) => { + const key = label.toLowerCase() as keyof typeof data; + return ( +
+ {label} + {left} +
+ {Array.from({ length: 6 }).map((_, i) => { + const colors = ["bg-green-400", "bg-green-300", "bg-teal-200", "bg-orange-200", "bg-orange-300", "bg-orange-400"]; + return ( + + ); + })} +
+ {right} +
+ ); + })} +
+ ); +} diff --git a/src/components/share-mii-button.tsx b/src/components/mii/share-mii-button.tsx similarity index 100% rename from src/components/share-mii-button.tsx rename to src/components/mii/share-mii-button.tsx diff --git a/src/components/mii/voice-viewer.tsx b/src/components/mii/voice-viewer.tsx new file mode 100644 index 0000000..c22a95e --- /dev/null +++ b/src/components/mii/voice-viewer.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { SwitchMiiInstructions } from "@/types"; +import { ChangeEvent } from "react"; + +interface Props { + data: SwitchMiiInstructions["voice"]; + onClick?: (e: ChangeEvent, label: string) => void; + onClickTone?: (i: number) => void; +} + +const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"]; + +export default function VoiceViewer({ data, onClick, onClickTone }: Props) { + return ( +
+ {VOICE_SETTINGS.map((label) => ( +
+ + { + if (onClick) onClick(e, label); + }} + /> +
+ ))} + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ ); +} 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 (); - const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState(); - - const [error, setError] = useState(undefined); + const [miiPortraitUri, setMiiPortraitUri] = useState(); + const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState(); const [name, setName] = useState(""); const [tags, setTags] = useState([]); const [description, setDescription] = useState(""); const [qrBytesRaw, setQrBytesRaw] = useState([]); + const [platform, setPlatform] = useState("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); + const handleSubmit = async () => { // Validate before sending request const nameValidation = nameSchema.safeParse(name); @@ -60,15 +115,36 @@ 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); - formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); files.forEach((file, index) => { // image1, image2, etc. formData.append(`image${index + 1}`, file); }); + if (platform === "THREE_DS") { + formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); + } else if (platform === "SWITCH") { + const response = await fetch(miiPortraitUri!); + + if (!response.ok) { + setError("Failed to check Mii portrait. Did you upload one?"); + return; + } + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + setError("Invalid image file returned"); + return; + } + + formData.append("gender", gender); + formData.append("miiPortraitImage", blob); + formData.append("instructions", JSON.stringify(instructions.current)); + } + const response = await fetch("/api/submit", { method: "POST", body: formData, @@ -84,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 () => { @@ -96,38 +172,43 @@ export default function SubmitForm() { return; } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; + // Convert QR code to JS (3DS) + let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii }; 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,12 +243,53 @@ export default function SubmitForm() {

+ {/* Platform select */} +
+ +
+ {/* Animated indicator */} + {/* TODO: maybe change width as part of animation? */} +
+ + {/* Switch button */} + + + {/* 3DS button */} + +
+
+ + {/* Name */}
+ {/* Description */}
-