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.
This commit is contained in:
trafficlunar 2025-09-13 14:53:17 +01:00
parent 066c215ea4
commit 20f1c51f0c
13 changed files with 612 additions and 262 deletions

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

@ -68,18 +68,20 @@ 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)
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 +155,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

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,29 +21,52 @@ 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
}); gender: z.enum(MiiGender).default("MALE"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// QR code
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
})
.refine(
(data) => {
// If platform is Switch, gender and miiPortraitImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Gender and Mii portrait image are required for Switch platform",
path: ["gender", "miiPortraitImage"],
}
);
export async function POST(request: NextRequest) { 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[];
@ -52,64 +75,85 @@ export async function POST(request: NextRequest) {
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"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
miiPortraitImage: formData.get("miiPortraitImage"),
qrBytesRaw: rawQrBytesRaw, qrBytesRaw: rawQrBytesRaw,
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, // Automatically detect certain information if on 3DS
lastName: conversion.tomodachiLifeMii.lastName, ...(data.platform === "THREE_DS" &&
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, conversion && {
islandName: conversion.tomodachiLifeMii.islandName, firstName: conversion.tomodachiLifeMii.firstName,
allowedCopying: conversion.mii.allowCopying, lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
}),
}, },
}); });
@ -117,33 +161,37 @@ export async function POST(request: NextRequest) {
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); 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);
} }
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 // 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");
@ -160,19 +208,25 @@ export async function POST(request: NextRequest) {
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer); await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.username!);
} 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("Error processing Mii files:", error); console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
try {
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
} }
// Compress and store user images // 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 +241,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

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

@ -109,13 +109,13 @@ 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 */}
@ -131,23 +131,25 @@ export default async function MiiPage({ params }: Props) {
<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 Gender */} {/* Mii Gender */}
<div className="grid grid-cols-2 gap-2"> <div className={`grid grid-cols-2 gap-2 ${mii.platform !== "THREE_DS" && "mt-4"}`}>
<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"

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,28 @@ 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 [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 +48,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,6 +63,7 @@ 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);
@ -69,6 +73,14 @@ export default function SubmitForm() {
formData.append(`image${index + 1}`, file); formData.append(`image${index + 1}`, file);
}); });
if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!);
const blob = await response.blob();
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
}
const response = await fetch("/api/submit", { const response = await fetch("/api/submit", {
method: "POST", method: "POST",
body: formData, body: formData,
@ -96,38 +108,41 @@ export default function SubmitForm() {
return; return;
} }
// Convert QR code to JS // Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; if (platform === "THREE_DS") {
try { let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
conversion = convertQrCode(qrBytes); try {
} catch (error) { conversion = convertQrCode(qrBytes);
setError(error instanceof Error ? error.message : String(error)); setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
return; } catch (error) {
setError(error instanceof Error ? error.message : String(error));
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 +177,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 +240,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,7 +256,54 @@ export default function SubmitForm() {
/> />
</div> </div>
{/* Separator */} {/* Gender (switch only) */}
{platform === "SWITCH" && (
<div className="w-full grid grid-cols-3 items-start">
<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"
onClick={() => setGender("FEMALE")}
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>
)}
{platform === "SWITCH" && (
<>
{/* Separator */}
<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>
<div className="flex flex-col items-center gap-2">
<PortraitUpload setImage={setMiiPortraitUri} />
</div>
</>
)}
{/* QR code selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <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" /> <hr className="flex-grow border-zinc-300" />
<span>QR Code</span> <span>QR Code</span>
@ -209,19 +313,20 @@ export default function SubmitForm() {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span> <span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2"> {platform === "THREE_DS" ? (
<Icon icon="mdi:camera" fontSize={20} /> <>
Use your camera <ThreeDsSubmitTutorialButton />
</button>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} /> <span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
<SubmitTutorialButton /> </>
) : (
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span> <SwitchSubmitTutorialButton />
)}
</div> </div>
{/* Separator */} {/* 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

@ -0,0 +1,131 @@
"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 ThreeDsSubmitTutorialButton() {
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 emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/allow-copying/step2.png" />
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/allow-copying/step3.png" />
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/allow-copying/step4.png" />
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/allow-copying/step5.png" />
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/allow-copying/step6.png" />
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/allow-copying/step7.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
<StartingPage emblaApi={emblaApi} />
{/* Create QR Code */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/create-qr-code/step2.png" />
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/create-qr-code/step3.png" />
<TutorialPage text="4. Select and press 'OK' on the Mii you wish to submit" imageSrc="/tutorial/create-qr-code/step4.png" />
<TutorialPage
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
imageSrc="/tutorial/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/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

@ -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 SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);

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
@ -197,16 +197,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