Compare commits

...

8 commits

Author SHA1 Message Date
07f3bb35d8 feat: 20f1c51f access key version 2025-10-29 17:18:18 +00:00
76fecca011 fix: remove island name from metadata in mii page
only automatically works for 3DS and I don't want to ask people for an
island name if on switch
2025-09-15 22:20:30 +01:00
f9dd7a396c style: fix responsiveness of filter menu 2025-09-14 17:17:41 +01:00
93e26b8937 fix: 'metadata' type images stretching mii portrait for switch miis 2025-09-14 17:11:59 +01:00
43c67d75a9 feat: platform filter, filtering redesign, show platform on mii pages 2025-09-14 15:27:13 +01:00
90a6b741be feat: groundwork for different platform tutorials 2025-09-14 12:36:11 +01:00
e1b269d99b Merge branch 'main' into feat/living-the-dream-qr-code 2025-09-14 12:25:52 +01:00
20f1c51f0c 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.
2025-09-13 15:03:12 +01:00
44 changed files with 1824 additions and 546 deletions

View file

@ -1,60 +1,66 @@
{ {
"name": "tomodachi-share", "name": "tomodachi-share",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.14.0", "packageManager": "pnpm@10.14.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.1.1", "@2toad/profanity": "^3.1.1",
"@auth/prisma-adapter": "2.10.0", "@auth/prisma-adapter": "2.10.0",
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.16.1",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.2.5",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"downshift": "^9.0.10", "downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^21.0.0", "file-type": "^21.0.0",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "15.5.3", "next": "16.0.0-beta.0",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.1.1", "react": "19.2.0",
"react-dom": "^19.1.1", "react-dom": "19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0", "react-webcam": "^7.2.0",
"satori": "^0.18.2", "satori": "^0.18.2",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.3.6", "swr": "^2.3.6",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@iconify/react": "^6.0.1", "@iconify/react": "^6.0.1",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.13",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.12", "@types/react": "19.2.2",
"@types/react-dom": "^19.1.9", "@types/react-dom": "19.2.1",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"eslint-config-next": "15.5.3", "eslint-config-next": "16.0.0-beta.0",
"prisma": "^6.16.1", "prisma": "^6.16.1",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} },
"pnpm": {
"overrides": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1"
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "accessKey" VARCHAR(7);

View file

@ -68,18 +68,21 @@ model Session {
} }
model Mii { model Mii {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
firstName String name String @db.VarChar(64)
lastName String imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS)
accessKey String? @db.VarChar(7)
firstName String?
lastName String?
gender MiiGender? gender MiiGender?
islandName String islandName String?
allowedCopying Boolean allowedCopying Boolean?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -153,6 +156,11 @@ model Punishment {
@@map("punishments") @@map("punishments")
} }
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender { enum MiiGender {
MALE MALE
FEMALE FEMALE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View file

@ -136,7 +136,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
} else if (description === undefined) { } else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image // 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 }); return rateLimit.sendResponse({ success: true });

View file

@ -7,7 +7,7 @@ import sharp from "sharp";
import qrcode from "qrcode-generator"; import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity"; import { profanity } from "@2toad/profanity";
import { MiiGender } from "@prisma/client"; import { MiiGender, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@ -21,95 +21,150 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z.object({ const submitSchema = z
name: nameSchema, .object({
tags: tagsSchema, platform: z.enum(MiiPlatform).default("THREE_DS"),
description: z.string().trim().max(256).optional(), name: nameSchema,
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" }), tags: tagsSchema,
image1: z.union([z.instanceof(File), z.any()]).optional(), description: z.string().trim().max(256).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(), // Switch
}); accessKey: z
.string()
.length(7, { error: "Access key must be 7 characters in length" })
.regex(/^[a-zA-Z0-9]+$/, "Access key must be alphanumeric"),
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" })
.optional(),
// 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, accessKey, gender, and miiPortraitImage must be present
if (data.platform === "SWITCH") {
return data.accessKey !== undefined && data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Access key, gender, and Mii portrait image is required for Switch",
path: ["accessKey", "gender", "miiPortraitImage"],
}
);
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json(); 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(); const formData = await request.formData();
let rawTags: string[]; let rawTags: string[];
let rawQrBytesRaw: string[]; // raw raw let rawQrBytesRaw: string[] | undefined = undefined; // good variable name - raw raw; is undefined for zod to ignore it if platform is Switch
try { try {
rawTags = JSON.parse(formData.get("tags") as string); rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string); rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch { } 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({ const parsed = submitSchema.safeParse({
platform: formData.get("platform"),
name: formData.get("name"), name: formData.get("name"),
tags: rawTags, tags: rawTags,
description: formData.get("description"), description: formData.get("description"),
qrBytesRaw: rawQrBytesRaw,
accessKey: formData.get("accessKey"),
gender: formData.get("gender"),
miiPortraitImage: formData.get("miiPortraitImage"),
qrBytesRaw: rawQrBytesRaw ?? undefined,
image1: formData.get("image1"), image1: formData.get("image1"),
image2: formData.get("image2"), image2: formData.get("image2"),
image3: formData.get("image3"), image3: formData.get("image3"),
}); });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); 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 // Censor potential inappropriate words
const name = profanity.censor(uncensoredName); const name = profanity.censor(data.name);
const tags = uncensoredTags.map((t) => profanity.censor(t)); const tags = data.tags.map((t) => profanity.censor(t));
const description = uncensoredDescription && profanity.censor(uncensoredDescription); const description = data.description && profanity.censor(data.description);
// Validate image files // 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; if (!img) continue;
const imageValidation = await validateImage(img); const imageValidation = await validateImage(img);
if (imageValidation.valid) { if (imageValidation.valid) {
images.push(img); customImages.push(img);
} else { } else {
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); 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 const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try { // Convert QR code to JS (3DS)
conversion = convertQrCode(qrBytes); let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
} catch (error) { if (data.platform === "THREE_DS") {
return rateLimit.sendResponse({ error }, 400); try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
}
} }
// Create Mii in database // Create Mii in database
const miiRecord = await prisma.mii.create({ const miiRecord = await prisma.mii.create({
data: { data: {
userId: Number(session.user.id), userId: Number(session.user.id),
platform: data.platform,
name, name,
tags, tags,
description, description,
gender: data.gender ?? "MALE",
firstName: conversion.tomodachiLifeMii.firstName, // Access key only for Switch
lastName: conversion.tomodachiLifeMii.lastName, accessKey: data.platform === "SWITCH" ? data.accessKey : null,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName, // Automatically detect certain information if on 3DS
allowedCopying: conversion.mii.allowCopying, ...(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,62 +172,74 @@ export async function POST(request: NextRequest) {
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true }); await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii
let studioBuffer: Buffer;
try { try {
const studioUrl = conversion.mii.studioUrl({ width: 512 }); let portraitBuffer: Buffer | undefined;
const studioResponse = await fetch(studioUrl);
if (!studioResponse.ok) { // Download the image of the Mii (3DS)
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); 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(); if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
studioBuffer = Buffer.from(studioArrayBuffer); const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(fileLocation, webpBuffer);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); 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);
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
if (data.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 generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
} }
try { 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.username!); await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) { } catch (error) {
// Clean up if something went wrong console.error(error);
await prisma.mii.delete({ where: { id: miiRecord.id } }); return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
console.error("Error processing Mii files:", error);
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
} }
// Compress and store user images // Compress and store user images
try { try {
await Promise.all( await Promise.all(
images.map(async (image, index) => { customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer()); const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
@ -187,7 +254,7 @@ export async function POST(request: NextRequest) {
id: miiRecord.id, id: miiRecord.id,
}, },
data: { data: {
imageCount: images.length, imageCount: customImages.length,
}, },
}); });
} catch (error) { } catch (error) {

View file

@ -64,6 +64,7 @@ body {
@apply block; @apply block;
} }
/* Tooltips */
[data-tooltip] { [data-tooltip] {
@apply relative z-10; @apply relative z-10;
} }
@ -81,7 +82,24 @@ body {
@apply opacity-100 scale-100; @apply opacity-100 scale-100;
} }
/* Scrollbar */ /* 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;
}
/* Scrollbars */
/* Firefox */ /* Firefox */
* { * {
scrollbar-color: #ff8903 transparent; scrollbar-color: #ff8903 transparent;

View file

@ -66,13 +66,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (imageType === "metadata" && mii) { if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly // 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...`); 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) { try {
return rateLimit.sendResponse({ error }, status); 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 { } else {
return rateLimit.sendResponse({ error: "Image not found" }, 404); return rateLimit.sendResponse({ error: "Image not found" }, 404);
} }

View file

@ -12,7 +12,8 @@ import LikeButton from "@/components/like-button";
import ImageViewer from "@/components/image-viewer"; import ImageViewer from "@/components/image-viewer";
import DeleteMiiButton from "@/components/delete-mii"; import DeleteMiiButton from "@/components/delete-mii";
import ShareMiiButton from "@/components/share-mii-button"; import ShareMiiButton from "@/components/share-mii-button";
import ScanTutorialButton from "@/components/tutorial/scan"; import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -47,13 +48,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`, 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 Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags], keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: username, creator: username,
openGraph: { openGraph: {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, 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 Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl], images: [metadataImageUrl],
publishedTime: mii.createdAt.toISOString(), publishedTime: mii.createdAt.toISOString(),
authors: username, authors: username,
@ -61,7 +62,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, 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 Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl], images: [metadataImageUrl],
creator: username, creator: username,
}, },
@ -109,45 +110,102 @@ export default async function MiiPage({ params }: Props) {
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1"> <div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2"> <div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */} {/* Mii Image */}
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center"> <div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50">
<ImageViewer <ImageViewer
src={`/mii/${mii.id}/image?type=mii`} src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={200} width={200}
height={200} height={200}
className="drop-shadow-lg hover:scale-105 transition-transform" className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
/> />
</div> </div>
{/* QR Code */} {/* QR Code/Access key */}
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer {mii.platform === "THREE_DS" ? (
src={`/mii/${mii.id}/image?type=qr-code`} <ImageViewer
alt="mii qr code" src={`/mii/${mii.id}/image?type=qr-code`}
width={128} alt="mii qr code"
height={128} width={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all" height={128}
/> className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
) : (
<h1 className="font-bold text-3xl">{mii.accessKey}</h1>
)}
</div> </div>
<hr className="w-full border-t-2 border-t-amber-400" /> <hr className="w-full border-t-2 border-t-amber-400" />
{/* Mii Info */} {/* Mii Info */}
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1"> {mii.platform === "THREE_DS" && (
<li> <ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
Name:{" "} <li>
<span className="text-right font-medium"> Name:{" "}
{mii.firstName} {mii.lastName} <span className="text-right font-medium">
</span> {mii.firstName} {mii.lastName}
</li> </span>
<li> </li>
From: <span className="text-right font-medium">{mii.islandName} Island</span> <li>
</li> From: <span className="text-right font-medium">{mii.islandName} Island</span>
<li> </li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox !cursor-auto" /> <li>
</li> Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" />
</ul> </li>
</ul>
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div
className={`tooltip !mt-1 ${
mii.platform === "THREE_DS"
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
}`}
>
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</div>
</div>
{/* Mii Gender */} {/* Mii Gender */}
<div className="grid grid-cols-2 gap-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="flex-grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
<div
className={`tooltip !mt-1 ${
mii.gender === "MALE"
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
}`}
>
{mii.gender === "MALE" ? "Male" : "Female"}
</div>
<div <div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${ className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300" mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
@ -231,7 +289,7 @@ export default async function MiiPage({ params }: Props) {
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</Link> </Link>
<ScanTutorialButton /> {mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
</div> </div>
</div> </div>
</div> </div>

View file

@ -95,9 +95,7 @@ export default async function ExiledPage() {
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex"> <div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} /> <Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4"> <div className="p-4">
<p className="text-xl font-bold line-clamp-1" title={"hello"}> <p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
{mii.mii.name}
</p>
<p className="text-sm"> <p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason} <span className="font-bold">Reason:</span> {mii.reason}
</p> </p>

View file

@ -0,0 +1,95 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import { MiiGender, MiiPlatform } from "@prisma/client";
import TagFilter from "./tag-filter";
import PlatformSelect from "./platform-select";
import GenderSelect from "./gender-select";
export default function FilterMenu() {
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const rawTags = searchParams.get("tags") || "";
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const tags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
);
const [filterCount, setFilterCount] = useState(tags.length);
// Filter menu button handler
const handleClick = () => {
if (!isOpen) {
setIsOpen(true);
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
} else {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 200);
}
};
// Count all active filters
useEffect(() => {
let count = tags.length;
if (platform) count++;
if (gender) count++;
setFilterCount(count);
}, [tags, platform, gender]);
return (
<div className="relative">
<button className="pill button gap-2" onClick={handleClick}>
<Icon icon="mdi:filter" className="text-xl" />
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
</button>
{isOpen && (
<div
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`}
>
{/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
</div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
</div>
<GenderSelect />
</div>
)}
</div>
);
}

View file

@ -29,24 +29,28 @@ export default function GenderSelect() {
}; };
return ( return (
<div className="grid grid-cols-2 gap-0.5"> <div className="grid grid-cols-2 gap-0.5 w-fit">
<button <button
onClick={() => handleClick("MALE")} onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis" aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${ data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip !bg-blue-400 !border-blue-400 before:!border-b-blue-400">Male</div>
<Icon icon="foundation:male" className="text-blue-400" /> <Icon icon="foundation:male" className="text-blue-400" />
</button> </button>
<button <button
onClick={() => handleClick("FEMALE")} onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis" aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${ data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip !bg-pink-400 !border-pink-400 before:!border-b-pink-400">Female</div>
<Icon icon="foundation:female" className="text-pink-400" /> <Icon icon="foundation:female" className="text-pink-400" />
</button> </button>
</div> </div>

View file

@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { MiiGender, Prisma } from "@prisma/client"; import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { z } from "zod"; import { z } from "zod";
@ -10,8 +10,7 @@ import { querySchema } from "@/lib/schemas";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import GenderSelect from "./gender-select"; import FilterMenu from "./filter-menu";
import TagFilter from "./tag-filter";
import SortSelect from "./sort-select"; import SortSelect from "./sort-select";
import Carousel from "../carousel"; import Carousel from "../carousel";
import LikeButton from "../like-button"; import LikeButton from "../like-button";
@ -36,6 +35,7 @@ const searchSchema = z.object({
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter((tag) => tag.length > 0) .filter((tag) => tag.length > 0)
), ),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(), gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema // todo: incorporate tagsSchema
// Pages // Pages
@ -60,7 +60,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data; const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -82,6 +82,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}), }),
// Tag filtering // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender // Gender
...(gender && { gender: { equals: gender } }), ...(gender && { gender: { equals: gender } }),
// Profiles // Profiles
@ -99,6 +101,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}, },
}, },
}), }),
platform: true,
name: true, name: true,
imageCount: true, imageCount: true,
tags: true, tags: true,
@ -184,7 +187,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return ( return (
<div className="w-full"> <div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-[56rem]:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{totalCount == filteredCount ? ( {totalCount == filteredCount ? (
<> <>
@ -201,9 +204,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2 w-full min-[56rem]:max-w-2/3 max-[56rem]:justify-center max-sm:flex-col"> <div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center">
<GenderSelect /> <FilterMenu />
<TagFilter />
<SortSelect /> <SortSelect />
</div> </div>
</div> </div>
@ -217,7 +219,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel <Carousel
images={[ images={[
`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`, ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`), ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]} ]}
/> />

View file

@ -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<MiiPlatform | null>((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 (
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("THREE_DS")}
aria-label="Filter for 3DS Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-sky-400 !border-sky-400 before:!border-b-sky-400">3DS</div>
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")}
aria-label="Filter for Switch Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-red-400 !border-red-400 before:!border-b-red-400">Switch</div>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</button>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import qrcode from "qrcode-generator"; import qrcode from "qrcode-generator";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import { convertQrCode } from "@/lib/qr-codes"; import { convertQrCode } from "@/lib/qr-codes";
@ -15,15 +16,29 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
import TagSelector from "../tag-selector"; import TagSelector from "../tag-selector";
import ImageList from "./image-list"; import ImageList from "./image-list";
import PortraitUpload from "./portrait-upload";
import QrUpload from "./qr-upload"; import QrUpload from "./qr-upload";
import QrScanner from "./qr-scanner"; import QrScanner from "./qr-scanner";
import SubmitTutorialButton from "../tutorial/submit"; import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit";
import LikeButton from "../like-button"; import LikeButton from "../like-button";
import Carousel from "../carousel"; import Carousel from "../carousel";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
export default function SubmitForm() { export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [accessKey, setAccessKey] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [files, setFiles] = useState<FileWithPath[]>([]); const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback( const handleDrop = useCallback(
@ -34,17 +49,6 @@ export default function SubmitForm() {
[files.length] [files.length]
); );
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate before sending request // Validate before sending request
const nameValidation = nameSchema.safeParse(name); const nameValidation = nameSchema.safeParse(name);
@ -60,15 +64,26 @@ export default function SubmitForm() {
// Send request to server // Send request to server
const formData = new FormData(); const formData = new FormData();
formData.append("platform", platform);
formData.append("name", name); formData.append("name", name);
formData.append("tags", JSON.stringify(tags)); formData.append("tags", JSON.stringify(tags));
formData.append("description", description); formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => { files.forEach((file, index) => {
// image1, image2, etc. // image1, image2, etc.
formData.append(`image${index + 1}`, file); 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!);
const blob = await response.blob();
formData.append("accessKey", accessKey);
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
}
const response = await fetch("/api/submit", { const response = await fetch("/api/submit", {
method: "POST", method: "POST",
body: formData, body: formData,
@ -84,6 +99,7 @@ export default function SubmitForm() {
}; };
useEffect(() => { useEffect(() => {
if (platform !== "THREE_DS") return;
if (qrBytesRaw.length == 0) return; if (qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw); const qrBytes = new Uint8Array(qrBytesRaw);
@ -100,34 +116,35 @@ export default function SubmitForm() {
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try { try {
conversion = convertQrCode(qrBytes); conversion = convertQrCode(qrBytes);
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : String(error)); setError(error instanceof Error ? error.message : String(error));
return; return;
} }
// Generate a new QR code for aesthetic reasons
try { try {
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes); const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L"); const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte"); generatedCode.addData(byteString, "Byte");
generatedCode.make(); generatedCode.make();
setGeneratedQrCodeUrl(generatedCode.createDataURL()); setGeneratedQrCodeUri(generatedCode.createDataURL());
} catch { } catch {
setError("Failed to get and/or generate Mii images"); setError("Failed to regenerate QR code");
} }
}; };
preview(); preview();
}, [qrBytesRaw]); }, [qrBytesRaw, platform]);
return ( return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel images={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} /> <Carousel
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
/>
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}> <h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -162,6 +179,46 @@ export default function SubmitForm() {
<hr className="flex-grow border-zinc-300" /> <hr className="flex-grow border-zinc-300" />
</div> </div>
{/* Platform select */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Platform
</label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
{/* Animated indicator */}
<div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
}`}
></div>
{/* Switch button */}
<button
type="button"
onClick={() => setPlatform("SWITCH")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch
</button>
{/* 3DS button */}
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS
</button>
</div>
</div>
{/* Name */}
<div className="w-full grid grid-cols-3 items-center"> <div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold"> <label htmlFor="name" className="font-semibold">
Name Name
@ -185,11 +242,13 @@ export default function SubmitForm() {
<TagSelector tags={tags} setTags={setTags} /> <TagSelector tags={tags} setTags={setTags} />
</div> </div>
{/* Description */}
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">
<label htmlFor="reason-note" className="font-semibold py-2"> <label htmlFor="description" className="font-semibold py-2">
Description Description
</label> </label>
<textarea <textarea
name="description"
rows={3} rows={3}
maxLength={256} maxLength={256}
placeholder="(optional) Type a description..." placeholder="(optional) Type a description..."
@ -199,29 +258,89 @@ export default function SubmitForm() {
/> />
</div> </div>
{/* Separator */} {platform === "SWITCH" && (
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <>
<hr className="flex-grow border-zinc-300" /> {/* Access Key */}
<span>QR Code</span> <div className="w-full grid grid-cols-3 items-center">
<hr className="flex-grow border-zinc-300" /> <label htmlFor="accessKey" className="font-semibold">
</div> Access Key <SwitchSubmitTutorialButton />
</label>
<input
name="accessKey"
type="text"
className="pill input w-full col-span-2"
minLength={7}
maxLength={7}
placeholder="Type your mii's access key here..."
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-2"> {/* Gender */}
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <div className="w-full grid grid-cols-3 items-start">
<span>or</span> <label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2"> <button
<Icon icon="mdi:camera" fontSize={20} /> type="button"
Use your camera onClick={() => setGender("FEMALE")}
</button> aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>
</div>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} /> {/* Mii Portrait */}
<SubmitTutorialButton /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="flex-grow border-zinc-300" />
</div>
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span> <div className="flex flex-col items-center gap-2">
</div> <PortraitUpload setImage={setMiiPortraitUri} />
</div>
</>
)}
{/* Separator */} {/* QR code selector */}
{platform === "THREE_DS" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
</>
)}
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="flex-grow border-zinc-300" />
<span>Custom images</span> <span>Custom images</span>

View file

@ -0,0 +1,36 @@
"use client";
import { useCallback } from "react";
import { FileWithPath } from "react-dropzone";
import Dropzone from "../dropzone";
interface Props {
setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
}
export default function PortraitUpload({ setImage }: Props) {
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0];
// Convert to Data URI
const reader = new FileReader();
reader.onload = async (event) => {
setImage(event.target!.result as string);
};
reader.readAsDataURL(file);
},
[setImage]
);
return (
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
Drag and drop your Mii&apos;s portrait here
<br />
or click to open
</p>
</Dropzone>
</div>
);
}

View file

@ -9,12 +9,11 @@ import QrFinder from "./qr-finder";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
interface Props { interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>; setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
} }
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { export default function QrScanner({ setQrBytesRaw }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null); const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
@ -127,105 +126,112 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
}; };
}, [isOpen, permissionGranted, scanQRCode]); }, [isOpen, permissionGranted, scanQRCode]);
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <>
<div <button type="button" aria-label="Use your camera" onClick={() => setIsOpen(true)} className="pill button gap-2">
onClick={close} <Icon icon="mdi:camera" fontSize={20} />
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ Use your camera
isVisible ? "opacity-100" : "opacity-0" </button>
}`}
/>
<div {isOpen && (
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${ <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" <div
}`} onClick={close}
> className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
<div className="flex justify-between items-center mb-2"> isVisible ? "opacity-100" : "opacity-0"
<h2 className="text-xl font-bold">Scan QR Code</h2> }`}
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> />
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
{devices.length > 1 && ( <div
<div className="mb-4 flex flex-col gap-1"> className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
<label className="text-sm font-semibold">Camera:</label> isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
<div className="relative w-full"> }`}
{/* Toggle button to open the dropdown */} >
<button <div className="flex justify-between items-center mb-2">
type="button" <h2 className="text-xl font-bold">Scan QR Code</h2>
aria-label="Select camera dropdown" <button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
{...getToggleButtonProps({}, { suppressRefError: true })} <Icon icon="material-symbols:close-rounded" />
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button> </button>
</div>
{/* Dropdown menu */} {devices.length > 1 && (
<ul <div className="mb-4 flex flex-col gap-1">
{...getMenuProps({}, { suppressRefError: true })} <label className="text-sm font-semibold">Camera:</label>
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${ <div className="relative w-full">
isDropdownOpen ? "block" : "hidden" {/* Toggle button to open the dropdown */}
}`} <button
> type="button"
{isDropdownOpen && aria-label="Select camera dropdown"
cameraItems.map((item, index) => ( {...getToggleButtonProps({}, { suppressRefError: true })}
<li className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
key={item.value} >
{...getItemProps({ item, index })} {selectedItem?.label || "Select a camera"}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
> <Icon icon="tabler:chevron-down" className="ml-2 size-5" />
{item.label} </button>
</li>
))} {/* Dropdown menu */}
</ul> <ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div> </div>
</div> </div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div> </div>
)}
<div className="mt-4 flex justify-center"> </>
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div>
</div>
</div>
); );
} }

View file

@ -14,32 +14,32 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
const handleDrop = useCallback( const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => { (acceptedFiles: FileWithPath[]) => {
acceptedFiles.forEach((file) => { const file = acceptedFiles[0];
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d"); // Scan QR code
if (!ctx) return; const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = image.width; const ctx = canvas.getContext("2d");
canvas.height = image.height; if (!ctx) return;
ctx.drawImage(image, 0, 0, image.width, image.height);
const imageData = ctx.getImageData(0, 0, image.width, image.height); canvas.width = image.width;
const code = jsQR(imageData.data, image.width, image.height); canvas.height = image.height;
if (!code) return; ctx.drawImage(image, 0, 0, image.width, image.height);
setQrBytesRaw(code.binaryData!); const imageData = ctx.getImageData(0, 0, image.width, image.height);
}; const code = jsQR(imageData.data, image.width, image.height);
image.src = event.target!.result as string; if (!code) return;
setQrBytesRaw(code.binaryData!);
}; };
reader.readAsDataURL(file); image.src = event.target!.result as string;
}); };
reader.readAsDataURL(file);
}, },
[setQrBytesRaw] [setQrBytesRaw]
); );

View file

@ -7,7 +7,7 @@ import { Icon } from "@iconify/react";
import TutorialPage from "./page"; import TutorialPage from "./page";
export default function ScanTutorialButton() { export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -66,11 +66,11 @@ export default function ScanTutorialButton() {
<div className="flex flex-col min-h-0 h-full"> <div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}> <div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full"> <div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" /> <TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/3ds/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/adding-mii/step2.png" /> <TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/3ds/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/adding-mii/step3.png" /> <TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/3ds/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/adding-mii/step4.png" /> <TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/3ds/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/adding-mii/step5.png" /> <TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/3ds/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} /> <TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@ import { Icon } from "@iconify/react";
import TutorialPage from "./page"; import TutorialPage from "./page";
import StartingPage from "./starting-page"; import StartingPage from "./starting-page";
export default function SubmitTutorialButton() { export default function ThreeDsSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -40,8 +40,8 @@ export default function SubmitTutorialButton() {
return ( return (
<> <>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"> <button type="button" onClick={() => setIsOpen(true)} className="text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to? (?)
</button> </button>
{isOpen && {isOpen &&
@ -69,7 +69,7 @@ export default function SubmitTutorialButton() {
<div className="flex flex-col min-h-0 h-full"> <div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}> <div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full"> <div className="flex h-full">
<StartingPage emblaApi={emblaApi} /> <StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */} {/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" /> <TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />

View file

@ -3,9 +3,10 @@ import { UseEmblaCarouselType } from "embla-carousel-react";
interface Props { interface Props {
emblaApi: UseEmblaCarouselType[1] | undefined; emblaApi: UseEmblaCarouselType[1] | undefined;
isSwitch?: boolean;
} }
export default function StartingPage({ emblaApi }: Props) { export default function StartingPage({ emblaApi, isSwitch }: Props) {
const goToTutorial = (index: number) => { const goToTutorial = (index: number) => {
if (!emblaApi) return; if (!emblaApi) return;
@ -29,7 +30,7 @@ export default function StartingPage({ emblaApi }: Props) {
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600" className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
> >
<Image <Image
src={"/tutorial/allow-copying/thumbnail.png"} src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
alt="Allow Copying thumbnail" alt="Allow Copying thumbnail"
width={128} width={128}
height={128} height={128}
@ -45,7 +46,7 @@ export default function StartingPage({ emblaApi }: Props) {
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600" className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
> >
<Image <Image
src={"/tutorial/create-qr-code/thumbnail.png"} src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
alt="Creating QR code thumbnail" alt="Creating QR code thumbnail"
width={128} width={128}
height={128} height={128}

View file

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import TutorialPage from "./page";
export default function SwitchScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/switch/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/switch/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/switch/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div>
</div>
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import TutorialPage from "./page";
import StartingPage from "./starting-page";
export default function SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/switch/allow-copying/step2.png" />
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/switch/allow-copying/step3.png" />
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/switch/allow-copying/step4.png" />
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/switch/allow-copying/step5.png" />
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/switch/allow-copying/step6.png" />
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/switch/allow-copying/step7.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
<StartingPage emblaApi={emblaApi} />
{/* Create QR Code */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/create-qr-code/step2.png" />
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/switch/create-qr-code/step3.png" />
<TutorialPage
text="4. Select and press 'OK' on the Mii you wish to submit"
imageSrc="/tutorial/switch/create-qr-code/step4.png"
/>
<TutorialPage
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
imageSrc="/tutorial/switch/create-qr-code/step5.png"
/>
<TutorialPage
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)."
imageSrc="/tutorial/switch/create-qr-code/step6.png"
/>
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} />
</div>
</div>
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -1,5 +1,5 @@
// This file's extension is .tsx because I am using JSX for satori to generate images // This file's extension is .tsx because JSX is used for satori to generate images
// These are disabled because satori is not Next.JS and is turned into an image anyways // Warnings below are disabled since satori is not Next.JS and is turned into an image anyways
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
@ -14,9 +14,9 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client"; import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = 128; const MIN_IMAGE_DIMENSIONS = [320, 240];
const MAX_IMAGE_DIMENSIONS = 1024; const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
//#region Image validation //#region Image validation
@ -43,12 +43,12 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if ( if (
!metadata.width || !metadata.width ||
!metadata.height || !metadata.height ||
metadata.width < MIN_IMAGE_DIMENSIONS || metadata.width < MIN_IMAGE_DIMENSIONS[0] ||
metadata.width > MAX_IMAGE_DIMENSIONS || metadata.width > MAX_IMAGE_DIMENSIONS[0] ||
metadata.height < MIN_IMAGE_DIMENSIONS || metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS metadata.height > MAX_IMAGE_DIMENSIONS[1]
) { ) {
return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" }; return { valid: false, error: "Image dimensions are invalid. Resolution must be between 320x240 and 1920x1080" };
} }
// Check for inappropriate content // Check for inappropriate content
@ -121,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
); );
}; };
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> { export async function generateMetadataImage(mii: Mii, author: string): Promise<Buffer> {
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString()); const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently // Load assets concurrently
@ -146,8 +146,14 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col"> <div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full"> <div tw="flex w-full">
{/* Mii image */} {/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}> <div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} /> <img
src={miiImage}
width={248}
height={248}
tw="w-full h-full"
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }}
/>
</div> </div>
{/* QR code */} {/* QR code */}
@ -197,16 +203,11 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file // Store the file
try { // I tried using .webp here but the quality looked awful
// I tried using .webp here but the quality looked awful // but it actually might be well-liked due to the hatred of .webp
// but it actually might be well-liked due to the hatred of .webp const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
const fileLocation = path.join(miiUploadsDirectory, "metadata.png"); await fs.writeFile(fileLocation, buffer);
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}
return { buffer }; return buffer;
} }
//#endregion //#endregion

View file

@ -1,28 +1,28 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"sjcl-with-all": ["./node_modules/@types/sjcl"] "sjcl-with-all": ["./node_modules/@types/sjcl"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }