Compare commits

...

16 commits

Author SHA1 Message Date
trafficlunar
27c45a0a02
Merge 13941e849c into 44be36b501 2026-03-01 15:17:51 +00:00
13941e849c feat: view instructions on mii page 2026-02-28 16:57:43 +00:00
5995afe3db feat: metadata images for switch platform
also some other changes
2026-02-28 12:33:36 +00:00
e31141ea39 feat: fake mii editor 2026-02-27 23:27:40 +00:00
d45eb07879 feat: nonbinary miis 2026-02-24 17:24:33 +00:00
0b1516e930 Merge branch 'main' into feat/living-the-dream-qr-code 2026-02-24 16:36:56 +00:00
118739041f Merge branch 'main' into feat/living-the-dream-qr-code 2026-02-20 15:23:11 +00:00
cd34fb983d feat: random stuff 2026-01-02 16:36:59 +00:00
2af1bf18a6 Merge branch 'main' into feat/living-the-dream-qr-code 2026-01-02 16:25:13 +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
79 changed files with 3181 additions and 279 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

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "MiiGender" ADD VALUE 'NONBINARY';

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "instructions" JSONB;

View file

@ -71,15 +71,18 @@ model Session {
model Mii {
id Int @id @default(autoincrement())
userId Int
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS)
firstName String
lastName String
instructions Json?
firstName String?
lastName String?
gender MiiGender?
islandName String
islandName String?
allowedCopying Boolean?
createdAt DateTime @default(now())
@ -154,9 +157,15 @@ model Punishment {
@@map("punishments")
}
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender {
MALE
FEMALE
NONBINARY
}
enum ReportType {

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

@ -139,7 +139,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
} else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image
try {
await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
}
return rateLimit.sendResponse({ success: true });

View file

@ -8,46 +8,75 @@ import sharp from "sharp";
import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity";
import { MiiGender } from "@prisma/client";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
import { SwitchMiiInstructions } from "@/types";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z.object({
const submitSchema = z
.object({
platform: z.enum(MiiPlatform).default("THREE_DS"),
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(256).optional(),
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, {
// Switch
gender: z.enum(MiiGender).default("MALE"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
instructions: switchMiiInstructionsSchema,
// QR code
qrBytesRaw: z
.array(z.number(), { error: "A QR code is required" })
.length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
}),
})
.nullish(),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
});
})
// This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, gender and miiPortraitImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Gender, Mii portrait image, and instructions are required for Switch platform",
path: ["gender", "miiPortraitImage", "instructions"],
},
);
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 2);
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
// Parse data
// Parse tags and QR code as JSON
const formData = await request.formData();
let rawTags: string[];
@ -62,18 +91,78 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
}
// Minify instructions to save space and improve user experience
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
if (formData.get("platform") === "SWITCH") {
function minify(object: Partial<SwitchMiiInstructions>): Partial<SwitchMiiInstructions> {
for (const key in object) {
const value = object[key as keyof SwitchMiiInstructions];
if (!value) {
delete object[key as keyof SwitchMiiInstructions];
continue;
}
// Recurse into nested objects
if (typeof value === "object") {
minify(value as Partial<SwitchMiiInstructions>);
if (Object.keys(value).length === 0) {
delete object[key as keyof SwitchMiiInstructions];
}
}
}
return object;
}
minifiedInstructions = minify(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
}
// Parse and check all submission info
const parsed = submitSchema.safeParse({
platform: formData.get("platform"),
name: formData.get("name"),
tags: rawTags,
description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
miiPortraitImage: formData.get("miiPortraitImage"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw,
image1: formData.get("image1"),
image2: formData.get("image2"),
image3: formData.get("image3"),
});
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data;
if (!parsed.success) {
const error = parsed.error.issues[0].message;
const issues = parsed.error.issues;
const hasInstructionsErrors = issues.some((issue) => issue.path[0] === "instructions");
if (hasInstructionsErrors) {
Sentry.captureException(error, {
extra: { issues, rawInstructions: formData.get("instructions"), stage: "submit-instructions" },
});
}
return rateLimit.sendResponse({ error }, 400);
}
const {
platform,
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
gender,
miiPortraitImage,
instructions,
image1,
image2,
image3,
} = parsed.data;
// Censor potential inappropriate words
const name = profanity.censor(uncensoredName);
@ -81,43 +170,60 @@ export async function POST(request: NextRequest) {
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files
const images: File[] = [];
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
if (!img) continue;
const imageValidation = await validateImage(img);
if (imageValidation.valid) {
images.push(img);
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
}
const qrBytes = new Uint8Array(qrBytesRaw);
// Check Mii portrait image as well (Switch)
if (platform === "SWITCH") {
const imageValidation = await validateImage(miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
// Convert QR code to JS
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined;
if (platform === "THREE_DS") {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
Sentry.captureException(error, { extra: { stage: "qr-conversion" } });
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
}
}
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
userId: Number(session.user.id),
platform,
name,
tags,
description,
gender: gender ?? "MALE",
// Automatically detect certain information if on 3DS
...(platform === "THREE_DS"
? conversion && {
firstName: conversion.tomodachiLifeMii.firstName,
lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
}
: {
instructions: minifiedInstructions,
}),
},
});
@ -125,34 +231,39 @@ export async function POST(request: NextRequest) {
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii
let studioBuffer: Buffer;
try {
const studioUrl = conversion.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl);
let portraitBuffer: Buffer | undefined;
// Download the image of the Mii (3DS)
if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl!);
if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
}
const studioArrayBuffer = await studioResponse.arrayBuffer();
studioBuffer = Buffer.from(studioArrayBuffer);
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
}
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(fileLocation, webpBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download Mii image:", error);
console.error("Failed to download/store Mii portrait:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
if (platform === "THREE_DS") {
try {
// 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");
@ -169,7 +280,6 @@ export async function POST(request: NextRequest) {
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
@ -178,11 +288,20 @@ export async function POST(request: NextRequest) {
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
}
}
try {
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
console.error(error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "metadata-image" } });
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
}
// Compress and store user images
try {
await Promise.all(
images.map(async (image, index) => {
customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
@ -197,7 +316,7 @@ export async function POST(request: NextRequest) {
id: miiRecord.id,
},
data: {
imageCount: images.length,
imageCount: customImages.length,
},
});
} catch (error) {

View file

@ -91,6 +91,23 @@ body {
@apply opacity-100 scale-100;
}
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
/* Scrollbar */
/* Firefox */
* {
@ -101,3 +118,35 @@ body {
*::-webkit-scrollbar-track {
background: #ff8903;
}
/* Range input */
input[type="range"] {
@apply appearance-none bg-transparent not-disabled:cursor-pointer;
}
/* Track */
input[type="range"]::-webkit-slider-runnable-track {
@apply h-2 bg-orange-200 border-2 border-orange-400 rounded-full;
}
input[type="range"]::-moz-range-track {
@apply h-1 bg-orange-200 border-2 border-orange-400 rounded-full;
}
/* Thumb */
input[type="range"]::-webkit-slider-thumb {
@apply appearance-none size-4 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition -mt-1.5;
}
input[type="range"]::-moz-range-thumb {
@apply size-3.5 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition;
}
/* Hover */
input[type="range"]:hover::-webkit-slider-thumb {
@apply not-disabled:bg-orange-500;
}
input[type="range"]:hover::-moz-range-thumb {
@apply not-disabled:bg-orange-500;
}

View file

@ -7,14 +7,18 @@ import { Icon } from "@iconify/react";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { MiiPlatform } from "@prisma/client";
import LikeButton from "@/components/like-button";
import ImageViewer from "@/components/image-viewer";
import DeleteMiiButton from "@/components/delete-mii";
import ShareMiiButton from "@/components/share-mii-button";
import ScanTutorialButton from "@/components/tutorial/scan";
import ProfilePicture from "@/components/profile-picture";
import DeleteMiiButton from "@/components/mii/delete-mii-button";
import ShareMiiButton from "@/components/mii/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
import Description from "@/components/description";
import MiiInstructions from "@/components/mii/instructions";
import { SwitchMiiInstructions } from "@/types";
interface Props {
params: Promise<{ id: string }>;
@ -30,6 +34,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
include: {
user: {
select: {
name: true,
username: true,
},
},
@ -44,28 +49,36 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const username = `@${mii.user.username}`;
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: username,
creator: mii.user.username,
openGraph: {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
publishedTime: mii.createdAt.toISOString(),
authors: username,
authors: mii.user.username,
},
twitter: {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
creator: username,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
creator: mii.user.username!,
},
alternates: {
canonical: `/mii/${mii.id}`,
@ -110,18 +123,19 @@ export default async function MiiPage({ params }: Props) {
<div className="flex flex-col items-center">
<div className="max-w-5xl w-full flex flex-col gap-4">
<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 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer
src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={200}
height={200}
className="drop-shadow-lg hover:scale-105 transition-transform"
width={250}
height={250}
className="drop-shadow-lg hover:scale-105 transition-transform w-full max-h-96 object-contain"
/>
</div>
{/* QR Code */}
{mii.platform === "THREE_DS" && (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
@ -131,9 +145,11 @@ export default async function MiiPage({ params }: Props) {
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
</div>
)}
<hr className="w-full border-t-2 border-t-amber-400" />
{/* Mii Info */}
{mii.platform === "THREE_DS" && (
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<li>
Name:{" "}
@ -144,17 +160,67 @@ export default async function MiiPage({ params }: Props) {
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
{mii.allowedCopying !== null && (
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox cursor-auto!" />
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox cursor-auto!" />
</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="grow border-zinc-300" />
<span>Platform</span>
<hr className="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-13 text-3xl 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-13 text-3xl 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 */}
<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="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="flex gap-1">
<div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${
className={`tooltip mt-1! ${
mii.gender === "MALE"
? "bg-blue-400! border-blue-400! before:border-b-blue-400!"
: mii.gender === "FEMALE"
? "bg-pink-400! border-pink-400! before:border-b-pink-400!"
: "bg-purple-400! border-purple-400! before:border-b-purple-400!"
}`}
>
{mii.gender === "MALE" ? "Male" : mii.gender === "FEMALE" ? "Female" : "Nonbinary"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
}`}
>
@ -162,12 +228,22 @@ export default async function MiiPage({ params }: Props) {
</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-13 text-5xl border-2 shadow-sm ${
mii.gender === "FEMALE" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</div>
{mii.platform !== "THREE_DS" && (
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "NONBINARY" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</div>
)}
</div>
</div>
@ -230,8 +306,11 @@ export default async function MiiPage({ params }: Props) {
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</Link>
<ScanTutorialButton />
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
</div>
{/* Instructions */}
{mii.platform === "SWITCH" && <MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} />}
</div>
</div>
@ -269,7 +348,7 @@ export default async function MiiPage({ params }: Props) {
))}
</div>
) : (
<p className="indent-8 text-black/50">There is nothing here...</p>
<p className="indent-7.5 text-black/50">There is nothing here...</p>
)}
</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">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1" title={"hello"}>
{mii.mii.name}
</p>
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>

View file

@ -7,8 +7,8 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import Countdown from "@/components/countdown";
import MiiList from "@/components/mii-list";
import Skeleton from "@/components/mii-list/skeleton";
import MiiList from "@/components/mii/list";
import Skeleton from "@/components/mii/list/skeleton";
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;

View file

@ -5,8 +5,8 @@ import { Suspense } from "react";
import { prisma } from "@/lib/prisma";
import ProfileInformation from "@/components/profile-information";
import MiiList from "@/components/mii-list";
import Skeleton from "@/components/mii-list/skeleton";
import MiiList from "@/components/mii/list";
import Skeleton from "@/components/mii/list/skeleton";
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;

View file

@ -5,8 +5,8 @@ import { Suspense } from "react";
import { auth } from "@/lib/auth";
import ProfileInformation from "@/components/profile-information";
import Skeleton from "@/components/mii-list/skeleton";
import MiiList from "@/components/mii-list";
import Skeleton from "@/components/mii/list/skeleton";
import MiiList from "@/components/mii/list";
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;

View file

@ -24,7 +24,7 @@ export default function ControlCenter() {
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
name="submit"
id="submit"
type="checkbox"
className="checkbox size-6!"
placeholder="Enter banner text"

View file

@ -30,7 +30,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
<div className="flex justify-center items-center gap-2">
<input
type="checkbox"
name="agreement"
id="agreement"
disabled={hasExpired}
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}

View file

@ -12,14 +12,16 @@ interface Props {
}
export default function Carousel({ images, className }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel();
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!emblaApi) return;
emblaApi.reInit();
setScrollSnaps(emblaApi.scrollSnapList());
setSelectedIndex(0);
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [images, emblaApi]);
@ -74,6 +76,8 @@ export default function Carousel({ images, className }: Props) {
>
<Icon icon="ic:round-chevron-right" />
</button>
</>
)}
<div className="flex justify-center p-2 gap-2 absolute right-0">
{scrollSnaps.map((_, index) => (
@ -86,8 +90,6 @@ export default function Carousel({ images, className }: Props) {
/>
))}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { ChangeEvent } from "react";
import { MiiGender } from "@prisma/client";
import { SwitchMiiInstructions } from "@/types";
interface Props {
data: SwitchMiiInstructions["datingPreferences"];
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
}
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
return (
<div className="flex flex-col gap-1.5">
{DATING_PREFERENCES.map((gender) => {
const genderEnum = gender.toUpperCase() as MiiGender;
return (
<div className="flex gap-1.5">
<input
key={gender}
type="checkbox"
id={gender}
className="checkbox"
checked={data.includes(genderEnum)}
onChange={(e) => {
if (onChecked) onChecked(e, genderEnum);
}}
/>
<label htmlFor={gender} className="text-sm select-none">
{gender}
</label>
</div>
);
})}
</div>
);
}

View file

@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import LikeButton from "./like-button";
import SubmitButton from "./submit-button";
import LikeButton from "../like-button";
import SubmitButton from "../submit-button";
interface Props {
miiId: number;

View file

@ -0,0 +1,272 @@
import React from "react";
import DatingPreferencesViewer from "./dating-preferences";
import VoiceViewer from "./voice-viewer";
import PersonalityViewer from "./personality-viewer";
import { SwitchMiiInstructions } from "@/types";
import { Icon } from "@iconify/react";
import { COLORS } from "@/lib/switch";
interface Props {
instructions: Partial<SwitchMiiInstructions>;
}
interface SectionProps {
name: string;
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
children?: React.ReactNode;
isSubSection?: boolean;
}
const ORDINAL_SUFFIXES: Record<string, string> = {
one: "st",
two: "nd",
few: "rd",
other: "th",
};
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1;
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
}
function ColorPosition({ color }: { color: number }) {
if (!color) return null;
if (color <= 7) {
return (
<>
Color menu on left, <GridPosition index={color} cols={1} />
</>
);
}
if (color >= 108) {
return (
<>
Outside color menu, <GridPosition index={color - 108} cols={2} />
</>
);
}
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} />
</span>
);
}
interface TableCellProps {
label: string;
children: React.ReactNode;
}
function TableCell({ label, children }: TableCellProps) {
return (
<tr className={"border-b border-orange-300/50 last:border-0"}>
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
<td className={"py-0.5 text-amber-950"}>{children}</td>
</tr>
);
}
function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object") return null;
const type = "type" in instructions ? instructions.type : undefined;
const color = "color" in instructions ? instructions.color : undefined;
const height = "height" in instructions ? instructions.height : undefined;
const distance = "distance" in instructions ? instructions.distance : undefined;
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
const size = "size" in instructions ? instructions.size : undefined;
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return (
<div className={`p-3 ${isSubSection ? "mt-2" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
<table className="w-full">
<tbody>
{type && (
<TableCell label="Type">
<GridPosition index={type} />
</TableCell>
)}
{color && (
<TableCell label="Color">
<ColorPosition color={color} />
</TableCell>
)}
{height && <TableCell label="Height">{height}</TableCell>}
{distance && <TableCell label="Distance">{distance}</TableCell>}
{rotation && <TableCell label="Rotation">{rotation}</TableCell>}
{size && <TableCell label="Size">{size}</TableCell>}
{stretch && <TableCell label="Stretch">{stretch}</TableCell>}
{children}
</tbody>
</table>
</div>
);
}
export default function MiiInstructions({ instructions }: Props) {
if (Object.keys(instructions).length === 0) return null;
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, datingPreferences, voice, personality } = instructions;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<Icon icon="fa7-solid:list" />
Instructions
</h2>
{head && <Section name="Head" instructions={head}></Section>}
{hair && (
<Section name="Hair" instructions={hair}>
{hair.setType && (
<TableCell label="Set Type">
<GridPosition index={hair.setType} />
</TableCell>
)}
{hair.bangsType && (
<TableCell label="Bangs Type">
<GridPosition index={hair.bangsType} />
</TableCell>
)}
{hair.backType && (
<TableCell label="Back Type">
<GridPosition index={hair.backType} />
</TableCell>
)}
{hair.subColor && (
<TableCell label="Sub Color">
<ColorPosition color={hair.subColor} />
</TableCell>
)}
</Section>
)}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && (
<Section name="Eyes" instructions={eyes}>
{eyes.eyesType && (
<TableCell label="Eyes Type">
<GridPosition index={eyes.eyesType} />
</TableCell>
)}
{eyes.eyelashesTop && (
<TableCell label="Eyelashes Top Type">
<GridPosition index={eyes.eyelashesTop} />
</TableCell>
)}
{eyes.eyelashesBottom && (
<TableCell label="Eyelashes Bottom Type">
<GridPosition index={eyes.eyelashesBottom} />
</TableCell>
)}
{eyes.eyelidTop && (
<TableCell label="Eyelid Top Type">
<GridPosition index={eyes.eyelidTop} />
</TableCell>
)}
{eyes.eyelidBottom && (
<TableCell label="Eyelid Bottom Type">
<GridPosition index={eyes.eyelidBottom} />
</TableCell>
)}
{eyes.eyeliner && (
<TableCell label="Eyeliner Type">
<GridPosition index={eyes.eyeliner} />
</TableCell>
)}
{eyes.pupil && (
<TableCell label="Pupil Type">
<GridPosition index={eyes.pupil} />
</TableCell>
)}
</Section>
)}
{nose && <Section name="Nose" instructions={nose}></Section>}
{lips && <Section name="Lips" instructions={lips}></Section>}
{ears && <Section name="Ears" instructions={ears}></Section>}
{glasses && (
<Section name="Glasses" instructions={glasses}>
{glasses.ringColor && (
<TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} />
</TableCell>
)}
{glasses.shadesColor && (
<TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} />
</TableCell>
)}
</Section>
)}
{other && (
<Section name="Other" instructions={other}>
<Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} />
<Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} />
<Section isSubSection name="Beard" instructions={other.beard} />
<Section isSubSection name="Moustache" instructions={other.moustache} />
<Section isSubSection name="Goatee" instructions={other.goatee} />
<Section isSubSection name="Mole" instructions={other.mole} />
<Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} />
<Section isSubSection name="Blush" instructions={other.blush} />
</Section>
)}
{(height || weight || datingPreferences || voice || personality) && (
<div className="pl-3 text-sm border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
{height && (
<div className="flex mb-1">
<label htmlFor="height" className="w-16">
Height
</label>
<input id="height" type="range" min={0} max={100} step={1} disabled value={height} />
</div>
)}
{weight && (
<div className="flex">
<label htmlFor="weight" className="w-16">
Weight
</label>
<input id="weight" type="range" min={0} max={100} step={1} disabled value={weight} />
</div>
)}
{datingPreferences && (
<div className="pl-2">
<h4 className="text-lg font-semibold mt-4">Dating Preferences</h4>
<div className="w-min">
<DatingPreferencesViewer data={datingPreferences} />
</div>
</div>
)}
{voice && (
<div className="pl-2">
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Voice</h4>
<div className="w-min">
<VoiceViewer data={voice} />
</div>
</div>
)}
{personality && (
<div className="pl-2">
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Personality</h4>
<div className="w-min">
<PersonalityViewer data={personality} />
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -4,8 +4,9 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import { MiiGender } from "@prisma/client";
import { MiiGender, MiiPlatform } from "@prisma/client";
import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters";
@ -16,9 +17,10 @@ export default function FilterMenu() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const rawTags = searchParams.get("tags") || "";
const rawExclude = searchParams.get("exclude") || "";
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo(
@ -61,11 +63,12 @@ export default function FilterMenu() {
// Count all active filters
useEffect(() => {
let count = tags.length + exclude.length;
if (platform) count++;
if (gender) count++;
if (allowCopying) count++;
setFilterCount(count);
}, [tags, exclude, gender, allowCopying]);
}, [tags, exclude, platform, gender, allowCopying]);
return (
<div className="relative">
@ -84,6 +87,20 @@ export default function FilterMenu() {
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="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="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Include</span>
<hr className="grow border-zinc-300" />
@ -97,19 +114,16 @@ export default function FilterMenu() {
</div>
<TagFilter isExclude />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
{platform !== "SWITCH" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Other</span>
<hr className="grow border-zinc-300" />
</div>
<OtherFilters />
</>
)}
</div>
)}
</div>

View file

@ -3,7 +3,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import { MiiGender } from "@prisma/client";
import { MiiGender, MiiPlatform } from "@prisma/client";
export default function GenderSelect() {
const router = useRouter();
@ -11,6 +11,7 @@ export default function GenderSelect() {
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
@ -31,26 +32,44 @@ export default function GenderSelect() {
};
return (
<div className="grid grid-cols-2 gap-0.5">
<div className="flex gap-0.5 w-fit">
<button
onClick={() => handleClick("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 ${
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"
}`}
>
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("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 ${
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"
}`}
>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
{platform !== "THREE_DS" && (
<button
onClick={() => handleClick("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import crypto from "crypto";
@ -11,9 +11,9 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import SortSelect from "./sort-select";
import Carousel from "../carousel";
import LikeButton from "../like-button";
import DeleteMiiButton from "../delete-mii";
import Carousel from "../../carousel";
import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button";
import Pagination from "./pagination";
import FilterMenu from "./filter-menu";
@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, exclude, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -52,6 +52,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
@ -71,6 +73,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
@ -143,7 +146,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
[totalCount, filteredCount, list] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
}
@ -156,7 +165,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return (
<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">
{totalCount == filteredCount ? (
<>
@ -188,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel
images={[
`/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`,
...(platform === "THREE_DS" ? `/mii/${mii.id}/image?type=qr-code` : ""),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>

View file

@ -32,7 +32,7 @@ export default function OtherFilters() {
<label htmlFor="allowCopying" className="text-sm">
Allow Copying
</label>
<input type="checkbox" name="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
</div>
);
}

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

@ -2,7 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../tag-selector";
import TagSelector from "../../tag-selector";
interface Props {
isExclude?: boolean;

View file

@ -0,0 +1,49 @@
"use client";
import { SwitchMiiInstructions } from "@/types";
interface Props {
data: SwitchMiiInstructions["personality"];
onClick?: (key: string, i: number) => void;
}
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
{ label: "Movement", left: "Slow", right: "Quick" },
{ label: "Speech", left: "Polite", right: "Honest" },
{ label: "Energy", left: "Flat", right: "Varied" },
{ label: "Thinking", left: "Serious", right: "Chill" },
{ label: "Overall", left: "Normal", right: "Quirky" },
];
export default function PersonalityViewer({ data, onClick }: Props) {
return (
<div className="flex flex-col gap-1.5 mb-3">
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
const key = label.toLowerCase() as keyof typeof data;
return (
<div key={label} className="flex justify-center items-center gap-2">
<span className="text-sm font-semibold w-24 shrink-0">{label}</span>
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
<div className="flex gap-0.5">
{Array.from({ length: 6 }).map((_, i) => {
const colors = ["bg-green-400", "bg-green-300", "bg-teal-200", "bg-orange-200", "bg-orange-300", "bg-orange-400"];
return (
<button
key={i}
type="button"
onClick={() => {
if (onClick) onClick(key, i);
}}
className={`size-7 rounded-lg transition-opacity duration-100 border-orange-500
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
></button>
);
})}
</div>
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { SwitchMiiInstructions } from "@/types";
import { ChangeEvent } from "react";
interface Props {
data: SwitchMiiInstructions["voice"];
onClick?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
onClickTone?: (i: number) => void;
}
const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"];
export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
return (
<div className="flex flex-col gap-1">
{VOICE_SETTINGS.map((label) => (
<div key={label} className="flex gap-3">
<label htmlFor={label} className="text-sm w-14">
{label}
</label>
<input
type="range"
name={label}
className="grow"
min={0}
max={100}
step={1}
value={data[label as keyof typeof data]}
disabled={!onClick}
onChange={(e) => {
if (onClick) onClick(e, label);
}}
/>
</div>
))}
<div className="flex gap-3">
<label htmlFor="delivery" className="text-sm w-14">
Tone
</label>
<div className="grid grid-cols-6 gap-1 grow">
{Array.from({ length: 6 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
if (onClickTone) onClickTone(i);
}}
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClick ? "hover:bg-orange-300 cursor-pointer" : ""}`}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -39,7 +39,7 @@ export default function DeleteAccount() {
return (
<>
<button name="deletion" onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
<button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
Delete Account
</button>

View file

@ -147,7 +147,7 @@ export default function EditForm({ mii, likes }: Props) {
Name
</label>
<input
name="name"
id="name"
type="text"
className="pill input w-full col-span-2"
minLength={2}

View file

@ -2,22 +2,27 @@
import { redirect } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react";
import qrcode from "qrcode-generator";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
import { SwitchMiiInstructions } from "@/types";
import TagSelector from "../tag-selector";
import ImageList from "./image-list";
import PortraitUpload from "./portrait-upload";
import QrUpload from "./qr-upload";
import QrScanner from "./qr-scanner";
import SubmitTutorialButton from "../tutorial/submit";
import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit";
import MiiEditor from "./mii-editor";
import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
import LikeButton from "../like-button";
import Carousel from "../carousel";
import SubmitButton from "../submit-button";
@ -35,16 +40,66 @@ export default function SubmitForm() {
);
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 [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE");
const instructions = useRef<SwitchMiiInstructions>({
head: { type: 0, skinColor: 0 },
hair: {
setType: 0,
bangsType: 0,
backType: 0,
color: 0,
subColor: 0,
style: 0,
isFlipped: false,
},
eyebrows: { type: 0, color: 0, height: 0, distance: 0, rotation: 0, size: 0, stretch: 0 },
eyes: {
eyesType: 0,
eyelashesTop: 0,
eyelashesBottom: 0,
eyelidTop: 0,
eyelidBottom: 0,
eyeliner: 0,
pupil: 0,
color: 0,
height: 0,
distance: 0,
rotation: 0,
size: 0,
stretch: 0,
},
nose: { type: 0, height: 0, size: 0 },
lips: { type: 0, color: 0, height: 0, rotation: 0, size: 0, stretch: 0, hasLipstick: false },
ears: { type: 0, height: 0, size: 0 },
glasses: { type: 0, ringColor: 0, shadesColor: 0, height: 0, size: 0, stretch: 0 },
other: {
wrinkles1: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
wrinkles2: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
beard: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
moustache: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
goatee: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
mole: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
eyeShadow: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
blush: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 },
},
height: 0,
weight: 0,
datingPreferences: [],
voice: { speed: 0, pitch: 0, depth: 0, delivery: 0, tone: 0 },
personality: { movement: 0, speech: 0, energy: 0, thinking: 0, overall: 0 },
});
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
@ -60,15 +115,36 @@ export default function SubmitForm() {
// Send request to server
const formData = new FormData();
formData.append("platform", platform);
formData.append("name", name);
formData.append("tags", JSON.stringify(tags));
formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => {
// image1, image2, etc.
formData.append(`image${index + 1}`, file);
});
if (platform === "THREE_DS") {
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
} else if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!);
if (!response.ok) {
setError("Failed to check Mii portrait. Did you upload one?");
return;
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
setError("Invalid image file returned");
return;
}
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
formData.append("instructions", JSON.stringify(instructions.current));
}
const response = await fetch("/api/submit", {
method: "POST",
body: formData,
@ -84,7 +160,7 @@ export default function SubmitForm() {
};
useEffect(() => {
if (qrBytesRaw.length == 0) return;
if (platform === "SWITCH" || qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw);
const preview = async () => {
@ -96,38 +172,43 @@ export default function SubmitForm() {
return;
}
// Convert QR code to JS
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii };
try {
conversion = convertQrCode(qrBytes);
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
return;
}
try {
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
// Generate a new QR code for aesthetic reasons
try {
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
setGeneratedQrCodeUrl(generatedCode.createDataURL());
setGeneratedQrCodeUri(generatedCode.createDataURL());
} catch {
setError("Failed to get and/or generate Mii images");
setError("Failed to regenerate QR code");
}
};
preview();
}, [qrBytesRaw]);
}, [qrBytesRaw, platform]);
return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-75 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",
...(platform === "THREE_DS" ? [generatedQrCodeUri ?? "/loading.svg"] : []),
...files.map((file) => URL.createObjectURL(file)),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -162,12 +243,53 @@ export default function SubmitForm() {
<hr className="grow border-zinc-300" />
</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 */}
{/* TODO: maybe change width as part of animation? */}
<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-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch
</button>
{/* 3DS button */}
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS
</button>
</div>
</div>
{/* Name */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Name
</label>
<input
name="name"
id="name"
type="text"
className="pill input w-full col-span-2"
minLength={2}
@ -185,11 +307,13 @@ export default function SubmitForm() {
<TagSelector tags={tags} setTags={setTags} showTagLimit />
</div>
{/* Description */}
<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
</label>
<textarea
id="description"
rows={5}
maxLength={256}
placeholder="(optional) Type a description..."
@ -199,7 +323,66 @@ export default function SubmitForm() {
/>
</div>
{/* Gender (switch only) */}
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
<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"
data-tooltip="Male"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
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"
data-tooltip="Female"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
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>
<button
type="button"
onClick={() => setGender("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip="Nonbinary"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
</div>
</div>
{/* (Switch Only) Mii Portrait */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<PortraitUpload setImage={setMiiPortraitUri} />
</div>
</div>
{/* (3DS only) QR code scanning */}
<div className={`${platform === "THREE_DS" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>QR Code</span>
@ -216,12 +399,28 @@ export default function SubmitForm() {
</button>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<SubmitTutorialButton />
<ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
</div>
{/* Separator */}
{/* (Switch only) Mii instructions */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>Mii Instructions</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton />
<span className="text-xs text-zinc-400">Instructions are recommended, but not required.</span>
</div>
</div>
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="grow border-zinc-300" />
<span>Custom images</span>

View file

@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { COLORS } from "@/lib/switch";
interface Props {
disabled?: boolean;
color: number;
setColor: (color: number) => void;
}
export default function ColorPicker({ disabled, color, setColor }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button
type="button"
onClick={() => {
if (isOpen) {
close();
} else {
setIsOpen(true);
}
}}
disabled={disabled}
className={`w-full flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
>
<Icon icon={"material-symbols:palette"} className="text-xl" />
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
</button>
{isOpen && (
<div
className={`absolute inset-0 w-122 p-4 bg-orange-100 rounded-lg transition-transform duration-500
flex items-center ${isVisible ? "opacity-100" : "opacity-0"}`}
style={{
transition: isVisible
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
}}
>
<div className="mr-8 flex flex-col gap-0.5">
{COLORS.slice(0, 8).map((c, i) => (
<button
type="button"
key={i}
onClick={() => setColor(i)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
// stagger by column then row for a wave effect
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<div className="grid grid-cols-10 gap-0.5">
{COLORS.slice(8, 108).map((c, i) => (
<button
type="button"
key={i + 8}
onClick={() => setColor(i + 8)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<button
type="button"
onClick={close}
className="absolute h-full w-16 top-0 right-0 cursor-pointer transition-transform hover:scale-115 active:scale-90"
>
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
</button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,82 @@
import { SwitchMiiInstructions } from "@/types";
import React, { useState } from "react";
import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair";
import EyebrowsTab from "./tabs/eyebrows";
import EyesTab from "./tabs/eyes";
import NoseTab from "./tabs/nose";
import LipsTab from "./tabs/lips";
import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc";
import { Icon } from "@iconify/react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
export const TAB_ICONS: Record<Tab, string> = {
head: "mingcute:head-fill",
hair: "mingcute:hair-fill",
eyebrows: "material-symbols:eyebrow",
eyes: "mdi:eye",
nose: "mingcute:nose-fill",
lips: "material-symbols-light:lips",
ears: "ion:ear",
glasses: "solar:glasses-bold",
other: "mingcute:head-ai-fill",
misc: "material-symbols:settings",
};
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
head: HeadTab,
hair: HairTab,
eyebrows: EyebrowsTab,
eyes: EyesTab,
nose: NoseTab,
lips: LipsTab,
ears: EarsTab,
glasses: GlassesTab,
other: OtherTab,
misc: MiscTab,
};
export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head");
const ActiveTab = TAB_COMPONENTS[tab];
return (
<>
<div className="w-full aspect-video flex bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
<div className="w-9 h-full flex flex-col">
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`size-9 flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
>
{/* ml because of border on left causing icons to look miscentered */}
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
</button>
))}
</div>
{/* Keep all tabs loaded to avoid flickering */}
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
const TabComponent = TAB_COMPONENTS[t];
return (
<div key={t} className={t === tab ? "grow flex" : "hidden"}>
<TabComponent instructions={instructions} />
</div>
);
})}
</div>
</>
);
}

View file

@ -0,0 +1,122 @@
import { useState } from "react";
interface Props {
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number };
}
export default function NumberInputs({ target }: Props) {
const [height, setHeight] = useState(0);
const [distance, setDistance] = useState(0);
const [rotation, setRotation] = useState(0);
const [size, setSize] = useState(0);
const [stretch, setStretch] = useState(0);
return (
<div className="grid grid-rows-5 h-full">
{target.height != undefined && (
<div className="w-full">
<label htmlFor="height" className="text-xs">
Height
</label>
<input
type="number"
id="height"
min={-5}
max={5}
value={height}
onChange={(e) => {
const value = Number(e.target.value);
setHeight(value);
target.height = value;
}}
className="pill input text-sm py-1! px-3! w-full"
/>
</div>
)}
{target.distance != undefined && (
<div className="w-full">
<label htmlFor="distance" className="text-xs">
Distance
</label>
<input
type="number"
id="distance"
min={-5}
max={5}
value={distance}
onChange={(e) => {
const value = Number(e.target.value);
setDistance(value);
target.distance = value;
}}
className="pill input text-sm py-1! px-3! w-full"
/>
</div>
)}
{target.rotation != undefined && (
<div className="w-full">
<label htmlFor="rotation" className="text-xs">
Rotation
</label>
<input
type="number"
id="rotation"
min={-5}
max={5}
value={rotation}
onChange={(e) => {
const value = Number(e.target.value);
setRotation(value);
target.rotation = value;
}}
className="pill input text-sm py-1! px-3! w-full"
/>
</div>
)}
{target.size != undefined && (
<div className="w-full">
<label htmlFor="size" className="text-xs">
Size
</label>
<input
type="number"
id="size"
min={-5}
max={5}
value={size}
onChange={(e) => {
const value = Number(e.target.value);
setSize(value);
target.size = value;
}}
className="pill input text-sm py-1! px-3! w-full"
/>
</div>
)}
{target.stretch != undefined && (
<div className="w-full">
<label htmlFor="stretch" className="text-xs">
Stretch
</label>
<input
type="number"
id="stretch"
min={-5}
max={5}
value={stretch}
onChange={(e) => {
const value = Number(e.target.value);
setStretch(value);
target.stretch = value;
}}
className="pill input text-sm py-1! px-3! w-full"
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,39 @@
import { SwitchMiiInstructions } from "@/types";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EarsTab({ instructions }: Props) {
const [type, setType] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Ears</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
length={5}
type={type}
setType={(i) => {
setType(i);
instructions.current.ears.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<NumberInputs target={instructions.current.ears} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,49 @@
import { SwitchMiiInstructions } from "@/types";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EyebrowsTab({ instructions }: Props) {
const [type, setType] = useState(0);
const [color, setColor] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Eyebrows</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
hasNoneOption
length={35}
type={type}
setType={(i) => {
setType(i);
instructions.current.eyebrows.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.eyebrows.color = i;
}}
/>
<NumberInputs target={instructions.current.eyebrows} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { SwitchMiiInstructions } from "@/types";
import { useState } from "react";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; length: number; colorsDisabled?: number[] }[] = [
{ name: "eyesType", length: 50 },
{ name: "eyelashesTop", length: 40 },
{ name: "eyelashesBottom", length: 20 },
{ name: "eyelidTop", length: 10 },
{ name: "eyelidBottom", length: 5 },
{ name: "eyeliner", length: 15 },
{ name: "pupil", length: 3 },
];
export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
// One type/color state per tab
const [types, setTypes] = useState<number[]>(Array(TABS.length).fill(0));
const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0));
const currentTab = TABS[tab];
const setType = (value: number) => {
setTypes((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
instructions.current.eyes[currentTab.name] = value;
};
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
// TODO: check in actual game, temp
instructions.current.eyes.color = value;
};
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="absolute font-bold text-xl">Other</h1>
<div className="flex justify-center grow">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i}
</button>
))}
</div>
</div>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector hasNoneOption length={currentTab.length} type={types[tab]} setType={setType} />
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<div className={`${tab !== 0 ? "hidden" : "w-full"}`}>
<ColorPicker color={colors[tab]} setColor={setColor} />
</div>
<NumberInputs target={instructions.current.eyes} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import { SwitchMiiInstructions } from "@/types";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function GlassesTab({ instructions }: Props) {
const [type, setType] = useState(0);
const [ringColor, setRingColor] = useState(0);
const [shadesColor, setShadesColor] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Glasses</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
hasNoneOption
length={50}
type={type}
setType={(i) => {
setType(i);
instructions.current.glasses.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker
color={ringColor}
setColor={(i) => {
setRingColor(i);
instructions.current.glasses.ringColor = i;
}}
/>
<ColorPicker
color={shadesColor}
setColor={(i) => {
setShadesColor(i);
instructions.current.glasses.shadesColor = i;
}}
/>
<NumberInputs target={instructions.current.glasses} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
import { SwitchMiiInstructions } from "@/types";
import { useState } from "react";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "sets" | "bangs" | "back";
export default function HairTab({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("sets");
const [setsType, setSetsType] = useState(0);
const [bangsType, setBangsType] = useState(0);
const [backType, setBackType] = useState(0);
const [color, setColor] = useState(0);
const [subColor, setSubColor] = useState<number | null>(null);
const [isFlipped, setIsFlipped] = useState(false);
const type = tab === "sets" ? setsType : tab === "bangs" ? bangsType : backType;
const setType = (value: number) => {
if (tab === "sets") {
setSetsType(value);
instructions.current.hair.setType = value;
} else if (tab === "bangs") {
setBangsType(value);
instructions.current.hair.bangsType = value;
} else {
setBackType(value);
instructions.current.hair.backType = value;
}
};
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="absolute font-bold text-xl">Hair</h1>
<div className="flex justify-center grow">
<button
type="button"
onClick={() => setTab("sets")}
className={`px-3 py-1 rounded-2xl bg-orange-200 mr-1 cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "sets" ? "bg-orange-300!" : "orange-200"}`}
>
Sets
</button>
<div className="rounded-2xl bg-orange-200 flex">
<button
type="button"
onClick={() => setTab("bangs")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "bangs" ? "bg-orange-300!" : "orange-200"}`}
>
Bangs
</button>
<button
type="button"
onClick={() => setTab("back")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "back" ? "bg-orange-300!" : "orange-200"}`}
>
Back
</button>
</div>
</div>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
length={50}
type={type}
setType={(i) => {
setType(i);
// Update ref
if (tab === "sets") {
instructions.current.hair.setType = i;
} else if (tab === "bangs") {
instructions.current.hair.bangsType = i;
} else if (tab === "back") {
instructions.current.hair.backType = i;
}
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.hair.color = i;
}}
/>
<div className="flex gap-1.5 items-center mb-2 w-full">
<input type="checkbox" id="subcolor" className="checkbox" checked={subColor !== null} onChange={(e) => setSubColor(e.target.checked ? 0 : null)} />
<label htmlFor="subcolor" className="text-xs">
Sub color
</label>
</div>
<ColorPicker
disabled={subColor === null}
color={subColor ? subColor : 0}
setColor={(i) => {
setSubColor(i);
instructions.current.hair.subColor = i;
}}
/>
<div className="flex gap-1.5 items-center w-full mt-auto">
<input type="checkbox" id="subcolor" className="checkbox" checked={isFlipped} onChange={(e) => setIsFlipped(e.target.checked)} />
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { useState } from "react";
import ColorPicker from "../color-picker";
import { SwitchMiiInstructions } from "@/types";
import TypeSelector from "../type-selector";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
export default function HeadTab({ instructions }: Props) {
const [color, setColor] = useState(108);
const [type, setType] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Head</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
length={16}
type={type}
setType={(i) => {
setType(i);
instructions.current.head.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.head.skinColor = i;
}}
/>
<div className="grid grid-cols-2 gap-1 w-fit mt-auto">
{COLORS.map((hex, i) => (
<button
type="button"
key={i + 108}
onClick={() => setColor(i + 108)}
className={`size-9 rounded-lg cursor-pointer ring-offset-2 ring-orange-500 ${color === i + 108 ? "ring-2" : ""}`}
style={{ backgroundColor: `#${hex}` }}
></button>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { SwitchMiiInstructions } from "@/types";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function LipsTab({ instructions }: Props) {
const [type, setType] = useState(0);
const [color, setColor] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Lips</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
length={35}
type={type}
setType={(i) => {
setType(i);
instructions.current.lips.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.lips.color = i;
}}
/>
<NumberInputs target={instructions.current.lips} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,144 @@
import { useState } from "react";
import { MiiGender } from "@prisma/client";
import DatingPreferencesViewer from "@/components/mii/dating-preferences";
import VoiceViewer from "@/components/mii/voice-viewer";
import PersonalityViewer from "@/components/mii/personality-viewer";
import { SwitchMiiInstructions } from "@/types";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function HeadTab({ instructions }: Props) {
const [height, setHeight] = useState(50);
const [weight, setWeight] = useState(50);
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>([]);
const [voice, setVoice] = useState({
speed: 50,
pitch: 50,
depth: 50,
delivery: 50,
tone: 0,
});
const [personality, setPersonality] = useState({
movement: -1,
speech: -1,
energy: -1,
thinking: -1,
overall: -1,
});
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Misc</h1>
</div>
<div className="grow overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Body</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col">
<label htmlFor="height" className="text-sm">
Height
</label>
<input
type="range"
id="height"
min={0}
max={100}
step={1}
value={height}
onChange={(e) => {
setHeight(e.target.valueAsNumber);
instructions.current.height = e.target.valueAsNumber;
}}
/>
</div>
<div className="flex flex-col">
<label htmlFor="weight" className="text-sm">
Weight
</label>
<input
type="range"
id="weight"
min={0}
max={100}
step={1}
value={weight}
onChange={(e) => {
setWeight(e.target.valueAsNumber);
instructions.current.weight = e.target.valueAsNumber;
}}
/>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" />
<span>Dating Preferences</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-1.5">
<DatingPreferencesViewer
data={datingPreferences}
onChecked={(e, gender) => {
setDatingPreferences((prev) =>
e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender),
);
instructions.current.datingPreferences = datingPreferences;
}}
/>
</div>
</div>
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Voice</span>
<hr className="grow border-zinc-300" />
</div>
<VoiceViewer
data={voice}
onClick={(e, label) => {
setVoice((p) => ({ ...p, [label]: e.target.valueAsNumber }));
instructions.current.voice[label as keyof typeof voice] = e.target.valueAsNumber;
}}
onClickTone={(i) => {
setVoice((p) => ({ ...p, tone: i }));
instructions.current.voice.tone = i;
}}
/>
</div>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Personality</span>
<hr className="grow border-zinc-300" />
</div>
<PersonalityViewer
data={personality}
onClick={(key, i) => {
setPersonality((p) => ({ ...p, [key]: i }));
instructions.current.personality = personality;
}}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { SwitchMiiInstructions } from "@/types";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function NoseTab({ instructions }: Props) {
const [type, setType] = useState(0);
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="font-bold text-xl">Nose</h1>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector
length={35}
type={type}
setType={(i) => {
setType(i);
instructions.current.nose.type = i;
}}
/>
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<NumberInputs target={instructions.current.nose} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,87 @@
import { SwitchMiiInstructions } from "@/types";
import { useState } from "react";
import ColorPicker from "../color-picker";
import TypeSelector from "../type-selector";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["other"]; length: number; colorsDisabled?: number[] }[] = [
{ name: "wrinkles1", length: 50 },
{ name: "wrinkles2", length: 40 },
{ name: "beard", length: 20 },
{ name: "moustache", length: 10 },
{ name: "goatee", length: 5 },
{ name: "mole", length: 15 },
{ name: "eyeShadow", length: 3 },
{ name: "blush", length: 8, colorsDisabled: [6] },
];
export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
// One type/color state per tab
const [types, setTypes] = useState<number[]>(Array(TABS.length).fill(0));
const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0));
const currentTab = TABS[tab];
const isColorPickerDisabled = currentTab.colorsDisabled ? currentTab.colorsDisabled.includes(types[tab]) : false;
const setType = (value: number) => {
setTypes((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
instructions.current.other[currentTab.name].type = value;
};
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
instructions.current.other[currentTab.name].color = value;
};
return (
<div className="relative grow p-3 pb-0!">
<div className="flex h-full">
<div className="grow flex flex-col">
<div className="flex items-center h-8">
<h1 className="absolute font-bold text-xl">Other</h1>
<div className="flex justify-center grow">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i}
</button>
))}
</div>
</div>
</div>
<div className="flex justify-center h-74 mt-auto">
<TypeSelector hasNoneOption length={currentTab.length} type={types[tab]} setType={setType} />
</div>
</div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker disabled={isColorPickerDisabled} color={colors[tab]} setColor={setColor} />
<NumberInputs target={instructions.current.other[currentTab.name]} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
interface Props {
hasNoneOption?: boolean;
length: number;
type: number;
setType: (type: number) => void;
}
export default function TypeSelector({ hasNoneOption, length, type, setType }: Props) {
return (
<div className="grid grid-cols-5 gap-1 w-fit overflow-y-auto h-fit max-h-full">
{Array.from({ length }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => setType(i)}
className={`size-12 cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-xl ${type === i ? "bg-orange-400!" : ""} ${hasNoneOption && i === 0 ? "text-md" : "text-2xl"}`}
>
{hasNoneOption ? (i === 0 ? "None" : i) : i + 1}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useCallback, useState } 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 [hasImage, setHasImage] = useState(false);
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);
setHasImage(true);
};
reader.readAsDataURL(file);
},
[setImage]
);
return (
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
{!hasImage ? (
<>
Drag and drop your Mii&apos;s portrait here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone";
import jsQR from "jsqr";
import Dropzone from "../dropzone";
@ -10,11 +10,13 @@ interface Props {
}
export default function QrUpload({ setQrBytesRaw }: Props) {
const [hasImage, setHasImage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
acceptedFiles.forEach((file) => {
const file = acceptedFiles[0];
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
@ -35,11 +37,11 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
if (!code) return;
setQrBytesRaw(code.binaryData!);
setHasImage(true);
};
image.src = event.target!.result as string;
};
reader.readAsDataURL(file);
});
},
[setQrBytesRaw],
);
@ -48,9 +50,15 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
{!hasImage ? (
<>
Drag and drop your QR code image here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>

View file

@ -97,7 +97,7 @@ export default function TagSelector({ tags, setTags, showTagLimit, isExclude }:
<button
type="button"
aria-label="Delete Tag"
className="text-black cursor-pointer"
className="text-slate-800 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);

View file

@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'Mii List'",
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
},
{
text: "3. Select and edit the Mii you wish to submit",
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
},
{
text: "4. Click 'Other Settings' in the information screen",
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
},
{
text: "5. Click on 'Don't Allow' under the 'Copying' text",
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
},
{
text: "6. Press 'Allow'",
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
},
{
text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
},
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>
);
}

View file

@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide {
// step is never used, undefined is assumed as a step
@ -30,7 +29,7 @@ interface Props {
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map

View file

@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/switch/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/switch/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Create QR Code",
thumbnail: "/tutorial/switch/create-qr-code/thumbnail.png",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/switch/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/switch/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/switch/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/switch/create-qr-code/step5.png",
},
{
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",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -16,7 +16,7 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [128, 128];
const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_DIMENSIONS = [2000, 2000];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
@ -49,7 +49,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
) {
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080" };
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 2000x2000" };
}
// Check for inappropriate content
@ -134,30 +134,61 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
fs.readFile(path.join(miiUploadsDirectory, "mii.webp")).then((buffer) =>
sharp(buffer)
.png()
// extend to fix shadow bug on landscape pictures
.extend({
left: 16,
right: 16,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
mii.platform === "THREE_DS"
? fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
)
: Promise.resolve(null),
loadFonts(),
]);
const jsx: ReactNode = (
<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">
{/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-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)" }} />
{/* Mii portrait */}
<div
tw={`h-62 rounded-xl flex justify-center items-center mr-2 ${mii.platform === "THREE_DS" ? "w-80" : "w-100"}`}
style={{
backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);",
}}
>
<img
src={miiImage}
height={248}
tw="w-full h-full"
style={{
objectFit: "contain",
filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)",
}}
/>
</div>
{/* QR code */}
{mii.platform === "THREE_DS" ? (
<div tw="w-60 bg-amber-200 rounded-xl flex justify-center items-center">
<img src={qrCodeImage} width={190} height={190} tw="border-2 border-amber-300 rounded-lg" />
<img src={qrCodeImage!} width={190} height={190} tw="border-2 border-amber-300 rounded-lg" />
</div>
) : (
<div tw="w-40 bg-amber-200 rounded-xl flex flex-col justify-center items-center p-6">
<span tw="text-amber-900 font-extrabold text-xl text-center leading-tight">Switch Guide</span>
<p tw="text-amber-800 text-sm text-center mt-1.5">You need to manually create the Mii, visit site for instructions.</p>
<div tw="mt-auto bg-amber-600 rounded-lg w-full py-2 flex justify-center">
<span tw="text-white font-semibold">View Steps</span>
</div>
</div>
)}
</div>
<div tw="flex flex-col w-full h-30 relative">

View file

@ -4,7 +4,7 @@ import sjcl from "sjcl-with-all";
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
import Mii from "./mii.js/mii";
import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii";
import { ThreeDsTomodachiLifeMii, HairDyeMode } from "./three-ds-tomodachi-life-mii";
// AES-CCM encrypted Mii QR codes have some errata (https://www.3dbrew.org/wiki/AES_Registers#CCM_mode_pitfall)
// causing them to not be easily decryptable by asmcrypto
@ -23,7 +23,7 @@ const sjclCcmCtrMode:
// @ts-expect-error -- Referencing a private function that is not in the types.
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | never {
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | never {
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
// References (Credits: jaames, kazuki-4ys):
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
@ -89,7 +89,7 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
const buffer = Buffer.from(result); // Convert to node Buffer.
const mii = new Mii(buffer); // Will verify the Mii data further.
// Decrypt Tomodachi Life Mii data from encrypted QR code bytes.
const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes);
const tomodachiLifeMii = ThreeDsTomodachiLifeMii.fromBytes(bytes);
// Apply hair dye fields.
switch (tomodachiLifeMii.hairDyeMode) {

View file

@ -1,4 +1,4 @@
import { MiiGender } from "@prisma/client";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny
@ -58,7 +58,8 @@ export const searchSchema = z.object({
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', 'FEMALE', or 'NONBINARY' if on Switch platform" }).optional(),
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
// todo: incorporate tagsSchema
// Pages
@ -89,3 +90,191 @@ export const displayNameSchema = z
.regex(/^[a-zA-Z0-9-_. ']+$/, {
error: "Display name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
});
const colorSchema = z.number().int().min(0).max(107).optional();
const geometrySchema = z.number().int().min(-5).max(5).optional();
export const switchMiiInstructionsSchema = z
.object({
head: z.object({ type: z.number().int().min(1).max(16).optional(), skinColor: z.number().int().min(0).max(121).optional() }).optional(),
hair: z
.object({
setType: z.number().int().min(0).max(25).optional(),
bangsType: z.number().int().min(0).max(25).optional(),
backType: z.number().int().min(0).max(25).optional(),
color: colorSchema,
subColor: colorSchema,
style: z.number().int().min(0).max(3).optional(),
isFlipped: z.boolean().optional(),
})
.optional(),
eyebrows: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyes: z
.object({
eyesType: z.number().int().min(0).max(25).optional(),
eyelashesTop: z.number().int().min(0).max(6).optional(),
eyelashesBottom: z.number().int().min(0).max(25).optional(),
eyelidTop: z.number().int().min(0).max(2).optional(),
eyelidBottom: z.number().int().min(0).max(25).optional(),
eyeliner: z.number().int().min(0).max(25).optional(),
pupil: z.number().int().min(0).max(9).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
nose: z
.object({
type: z.number().int().min(0).max(25).optional(),
height: geometrySchema,
size: geometrySchema,
})
.optional(),
lips: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
hasLipstick: z.boolean().optional(),
})
.optional(),
ears: z
.object({
type: z.number().int().min(0).max(4).optional(),
height: geometrySchema,
size: geometrySchema,
})
.optional(),
glasses: z
.object({
type: z.number().int().min(0).max(25).optional(),
ringColor: colorSchema,
shadesColor: colorSchema,
height: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
other: z
.object({
wrinkles1: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
wrinkles2: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
beard: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
moustache: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
goatee: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
mole: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyeShadow: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
blush: z
.object({
type: z.number().int().min(0).max(25).optional(),
color: colorSchema,
height: geometrySchema,
distance: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
})
.optional(),
height: z.number().int().min(0).max(100).optional(),
weight: z.number().int().min(0).max(100).optional(),
datingPreferences: z.array(z.enum(MiiGender)).optional(),
voice: z
.object({
speed: z.number().int().min(0).max(100).optional(),
pitch: z.number().int().min(0).max(100).optional(),
depth: z.number().int().min(0).max(100).optional(),
delivery: z.number().int().min(0).max(100).optional(),
tone: z.number().int().min(1).max(6).optional(),
})
.optional(),
personality: z
.object({
movement: z.number().int().min(1).max(8).optional(),
speech: z.number().int().min(1).max(8).optional(),
energy: z.number().int().min(1).max(8).optional(),
thinking: z.number().int().min(1).max(8).optional(),
overall: z.number().int().min(1).max(8).optional(),
})
.optional(),
})
.optional();

136
src/lib/switch.ts Normal file
View file

@ -0,0 +1,136 @@
export const COLORS: string[] = [
// Outside
"000000",
"8E8E93",
"6B4F0F",
"5A2A0A",
"7A1E0E",
"A0522D",
"A56B2A",
"D4A15A",
// Row 1
"F2F2F2",
"E6D5C3",
"F3E6A2",
"CDE6A1",
"A9DFA3",
"8ED8B0",
"8FD3E8",
"C9C2E6",
"F3C1CF",
"F0A8A8",
// Row 2
"D8D8D8",
"E8C07D",
"F0D97A",
"CDE07A",
"7BC96F",
"6BC4B2",
"5BBAD6",
"D9A7E0",
"F7B6C2",
"F47C6C",
// Row 3
"C0C0C0",
"D9A441",
"F4C542",
"D4C86A",
"8FD14F",
"58B88A",
"6FA8DC",
"B4A7D6",
"F06277",
"FF6F61",
// Row 4
"A8A8A8",
"D29B62",
"F2CF75",
"D8C47A",
"8DB600",
"66C2A5",
"4DA3D9",
"C27BA0",
"D35D6E",
"FF4C3B",
// Row 5
"9A9A9A",
"C77800",
"F4B183",
"D6BF3A",
"3FA34D",
"4CA3A3",
"7EA6E0",
"B56576",
"FF1744",
"FF2A00",
// Row 6
"8A817C",
"B85C1E",
"FF8C00",
"D2B48C",
"2E8B57",
"2F7E8C",
"2E86C1",
"7D5BA6",
"C2185B",
"E0193A",
// Row 7
"6E6E6E",
"95543A",
"F4A460",
"B7A369",
"3B7A0A",
"1F6F78",
"3F51B5",
"673AB7",
"B71C1C",
"C91F3A",
// Row 8
"3E3E3E",
"8B5A2B",
"F0986C",
"9E8F2A",
"0B5D3B",
"0E3A44",
"1F2A44",
"4B2E2E",
"9C1B1B",
"7A3B2E",
// Row 9
"2E2E2E",
"7A4A2A",
"A86A1D",
"6E6B2A",
"2F6F55",
"004E52",
"1C2F6E",
"3A1F4D",
"A52A2A",
"8B4513",
// Row 10
"000000",
"5A2E0C",
"7B3F00",
"5C4A00",
"004225",
"003B44",
"0A1F44",
"2B1B3F",
"7B2D2D",
"8B3A0E",
// Head tab extra colors
"FFD8BA",
"FFD5AC",
"FEC1A4",
"FEC68F",
"FEB089",
"FEBA6B",
"F39866",
"E89854",
"E37E3F",
"B45627",
"914220",
"59371F",
"662D16",
"392D1E",
];

View file

@ -1,4 +1,4 @@
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "../lib/constants";
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "./constants";
import sjcl from "sjcl-with-all";
// @ts-expect-error - This is not in the types, but it's a function needed to enable CTR mode.
@ -19,7 +19,7 @@ export enum HairDyeMode {
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/ffl-renderer-proto-integrate/assets/kaitai-structs/tomodachi_life_qr_code.ksy
// (Credits to ariankordi for the byte locations)
export class TomodachiLifeMii {
export class ThreeDsTomodachiLifeMii {
firstName: string;
lastName: string;
islandName: string;
@ -73,7 +73,7 @@ export class TomodachiLifeMii {
this.studioHairColor = hairDyeConverter[this.hairDye];
}
static fromBytes(bytes: Uint8Array): TomodachiLifeMii {
static fromBytes(bytes: Uint8Array): ThreeDsTomodachiLifeMii {
const iv = bytes.subarray(0x70, 128);
const encryptedExtraData = bytes.subarray(128, -4);
@ -88,7 +88,7 @@ export class TomodachiLifeMii {
const decryptedExtraData = sjcl.codec.bytes.fromBits(decryptedBits);
const data = new Uint8Array(decryptedExtraData);
return new TomodachiLifeMii(data.buffer);
return new ThreeDsTomodachiLifeMii(data.buffer);
}
private readUtf16String(buffer: ArrayBuffer, offset: number, byteLength: number): string {

155
src/types.d.ts vendored
View file

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { MiiGender, Prisma } from "@prisma/client";
import { DefaultSession } from "next-auth";
declare module "next-auth" {
@ -12,3 +12,156 @@ declare module "next-auth" {
username?: string;
}
}
// All color properties are assumed to be the same 108 colors
interface SwitchMiiInstructions {
head: {
type: number; // 16 types
skinColor: number; // additional 14 are not in color menu
};
hair: {
setType: number; // at least 25
bangsType: number; // at least 25
backType: number; // at least 25
color: number;
subColor: number;
style: number; // is this different for each hair?
isFlipped: boolean; // is this different for bangs/back?
};
eyebrows: {
type: number; // 0 is None, at least 25 (including None)
color: number;
height: number;
distance: number;
rotation: number;
size: number;
stretch: number;
};
eyes: {
eyesType: number; // At least 25
eyelashesTop: number; // 6 types
eyelashesBottom: number; // unknown
eyelidTop: number; // 0 is None, 2 additional types
eyelidBottom: number; // unknown
eyeliner: number; // unknown
pupil: number; // 0 is default, 9 additional types
color: number; // is this same as hair?
height: number;
distance: number;
rotation: number;
size: number;
stretch: number;
};
nose: {
type: number; // 0 is None, at least 24 additional
height: number;
size: number;
};
lips: {
type: number; // 0 is None, at least 24 additional
color: number; // is this same as hair?
height: number;
rotation: number;
size: number;
stretch: number;
hasLipstick: boolean; // is this what it's called?
};
ears: {
type: number; // 0 is Default, 4 additional
height: number;
size: number;
};
glasses: {
type: number; // NOTE: THERE IS A GAP!!! 0 is None, at least 29 additional
ringColor: number; // i'm assuming based off icon
shadesColor: number; // i'm assuming based off icon
height: number;
size: number;
stretch: number;
};
other: {
// names were assumed
wrinkles1: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
wrinkles2: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
beard: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
moustache: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
goatee: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
mole: {
type: number; // 0 is None, at least BLANK additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
eyeShadow: {
type: number; // 0 is None, at least 3 additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
blush: {
type: number; // 0 is None, at least 7 additional
color: number; // is this same as hair?
height: number;
distance: number;
size: number;
stretch: number;
};
};
// makeup, use video?
height: number;
weight: number;
datingPreferences: MiiGender[];
voice: {
speed: number;
pitch: number;
depth: number;
delivery: number;
tone: number; // 1 to 6
};
personality: {
movement: number; // 8 levels, slow to quick
speech: number; // 8 levels, polite to honest
energy: number; // 8 levels, flat to varied
thinking: number; // 8 levels, serious to chill
overall: number; // 8 levels, normal to quirky
};
}