diff --git a/backend/package.json b/backend/package.json
index f45b104..4662f36 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -14,7 +14,9 @@
"@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1",
"@prisma/client": "^6.19.2",
+ "@tomodachi-share/shared": "workspace:*",
"bit-buffer": "^0.3.0",
+ "charinfo-ex": "^0.0.5",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"file-type": "^22.0.1",
@@ -27,8 +29,7 @@
"satori": "^0.26.0",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
- "zod": "^4.3.6",
- "@tomodachi-share/shared": "workspace:*"
+ "zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
diff --git a/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql b/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql
new file mode 100644
index 0000000..7c831e6
--- /dev/null
+++ b/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 2cda82c..0faf492 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -79,10 +79,11 @@ model Mii {
in_queue Boolean @default(false)
needsFixing String?
- instructions Json?
- youtubeId String?
- gender MiiGender?
- makeup MiiMakeup?
+ isFromSaveFile Boolean @default(false)
+ instructions Json?
+ youtubeId String?
+ gender MiiGender?
+ makeup MiiMakeup?
firstName String?
lastName String?
diff --git a/backend/src/app/api/mii/list/route.ts b/backend/src/app/api/mii/list/route.ts
index 8f5451b..f1d2d44 100644
--- a/backend/src/app/api/mii/list/route.ts
+++ b/backend/src/app/api/mii/list/route.ts
@@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
makeup,
allowCopying,
quarantined,
+ isFromSaveFile,
page = 1,
limit = 24,
parentPage,
@@ -59,17 +60,13 @@ export async function GET(request: NextRequest) {
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
- // Platform
+ // Other
...(platform && { platform: { equals: platform } }),
- // Gender
...(gender && { gender: { equals: gender } }),
- // Allow Copying
...(allowCopying && { allowedCopying: true }),
- // Makeup
...(makeup && { makeup: { equals: makeup } }),
- // Quarantined
...(!quarantined && !userId && { quarantined: false }),
- // Time range
+ ...(isFromSaveFile && { isFromSaveFile: true }),
...(timeRange && {
reviewedAt: {
gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]),
diff --git a/backend/src/app/api/submit/route.ts b/backend/src/app/api/submit/route.ts
index 178d776..53b64e3 100644
--- a/backend/src/app/api/submit/route.ts
+++ b/backend/src/app/api/submit/route.ts
@@ -15,60 +15,56 @@ import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images";
import Mii from "../../../../../shared/src/mii.js/mii";
-import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
+import { convertQrCode, minifyInstructions, SwitchTomodachiLifeMii, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
import { settings } from "../../../lib/settings";
+import { CharInfoEx } from "charinfo-ex";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
-const submitSchema = z
- .object({
- platform: z.enum(MiiPlatform).default("THREE_DS"),
- name: nameSchema,
- tags: tagsSchema,
- description: z.string().trim().max(512).optional(),
+const submitSchema = z.object({
+ platform: z.enum(MiiPlatform).default("THREE_DS"),
+ name: nameSchema,
+ tags: tagsSchema,
+ description: z.string().trim().max(512).optional(),
- // Switch
- gender: z.enum(MiiGender).default("MALE"),
- makeup: z.enum(MiiMakeup).default("PARTIAL"),
- miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
- miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
- youtubeId: z
- .string()
- .trim()
- .transform((val) => (val === "" ? null : val))
- .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
- .optional(),
- instructions: switchMiiInstructionsSchema,
+ // Switch
+ gender: z.enum(MiiGender).default("MALE"),
+ makeup: z.enum(MiiMakeup).default("PARTIAL"),
+ miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
+ youtubeId: z
+ .string()
+ .trim()
+ .transform((val) => (val === "" ? null : val))
+ .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
+ .optional(),
- // QR code
- qrBytesRaw: z
- .array(z.number(), { error: "A QR code is required" })
- .length(372, {
- error: "QR code size is not a valid Tomodachi Life QR code",
- })
- .nullish(),
+ way: z.enum(["savedata", "manual"]).optional(),
- // Custom images
- image1: z.union([z.instanceof(File), z.any()]).optional(),
- image2: z.union([z.instanceof(File), z.any()]).optional(),
- image3: z.union([z.instanceof(File), z.any()]).optional(),
- })
- // This refine function is probably useless
- .refine(
- (data) => {
- // If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
- if (data.platform === "SWITCH") {
- return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
- }
- return true;
- },
- {
- message: "Gender, Mii portrait & features image are required for Switch platform",
- path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
- },
- );
+ // Save data way
+ miiDataFile: z
+ .instanceof(File)
+ .refine((blob) => blob.size < 1024 * 1024 * 0.1, "File too large") // TODO: actual size
+ .optional(),
+
+ // Manual way
+ miiFeaturesImage: 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(),
+});
export async function POST(request: NextRequest) {
const session = await auth();
@@ -106,8 +102,12 @@ export async function POST(request: NextRequest) {
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
- miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"),
+ way: formData.get("way"),
+
+ miiDataFile: formData.get("miiDataFile") ?? undefined,
+
+ miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw,
@@ -131,6 +131,8 @@ export async function POST(request: NextRequest) {
qrBytesRaw,
gender,
makeup,
+ way,
+ miiDataFile,
miiPortraitImage,
miiFeaturesImage,
youtubeId,
@@ -161,9 +163,10 @@ export async function POST(request: NextRequest) {
// Check Mii portrait & features image (Switch)
if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage);
- const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
+
+ const featuresValidation = await validateImage(miiFeaturesImage);
if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
}
@@ -180,6 +183,21 @@ export async function POST(request: NextRequest) {
}
}
+ const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined;
+ const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined;
+
+ let parsedSwitchMii: SwitchTomodachiLifeMii | undefined = undefined;
+
+ if (way === "savedata") {
+ if (!miiData || !miiDataFileBuffer) return rateLimit.sendResponse({ error: "No valid Mii data provided" }, 400);
+ try {
+ parsedSwitchMii = new SwitchTomodachiLifeMii(miiDataFileBuffer, miiData);
+ } catch (error) {
+ console.warn("Failed to verify Switch Mii data", error);
+ return rateLimit.sendResponse({ error: "Failed to verify Mii data: is your ShareMii file up to date?" }, 400);
+ }
+ }
+
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
@@ -204,6 +222,7 @@ export async function POST(request: NextRequest) {
youtubeId,
instructions: minifiedInstructions,
makeup: makeup ?? "PARTIAL",
+ ...(way === "savedata" && { isFromSaveFile: true }),
}),
},
});
@@ -228,18 +247,20 @@ export async function POST(request: NextRequest) {
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
- // Save features image
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
- const pngBuffer = await sharp(featuresBuffer)
- .resize({
- height: 800,
- fit: "inside",
- withoutEnlargement: true,
- })
- .png({ quality: 85 })
- .toBuffer();
- const fileLocation = path.join(miiUploadsDirectory, "features.png");
- await fs.writeFile(fileLocation, pngBuffer);
+ const pngBuffer = await sharp(featuresBuffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
+ await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
+
+ if (way === "savedata" && miiDataFileBuffer) {
+ await fs.writeFile(path.join(miiUploadsDirectory, "data.ltd"), Buffer.from(miiDataFileBuffer));
+
+ if (parsedSwitchMii) {
+ const pngBuffer = await parsedSwitchMii.extractFacePaintImage();
+ if (pngBuffer) await fs.writeFile(path.join(miiUploadsDirectory, "facepaint.png"), pngBuffer);
+ } else {
+ return rateLimit.sendResponse({ error: "Failed to extract Switch Mii data" }, 500);
+ }
+ }
}
// Save portrait image
diff --git a/backend/src/app/mii/[id]/download/route.ts b/backend/src/app/mii/[id]/download/route.ts
new file mode 100644
index 0000000..3a93a7a
--- /dev/null
+++ b/backend/src/app/mii/[id]/download/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import fs from "fs/promises";
+import path from "path";
+
+import { prisma } from "@/lib/prisma";
+import { RateLimit } from "@/lib/rate-limit";
+import { idSchema } from "@tomodachi-share/shared/schemas";
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const rateLimit = new RateLimit(request, 4, "/mii/download");
+ const check = await rateLimit.handle();
+ if (check) return check;
+
+ const { id: slugId } = await params;
+ const parsed = idSchema.safeParse(slugId);
+ if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
+ const miiId = parsed.data;
+
+ const mii = await prisma.mii.findUnique({
+ where: { id: miiId },
+ });
+ if (!mii) return new NextResponse("Not found", { status: 404 });
+
+ try {
+ const buffer = await fs.readFile(path.join(process.cwd(), "uploads", "mii", miiId.toString(), "data.ltd"));
+ return new NextResponse(buffer, {
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Disposition": `attachment; filename="${mii.name}.ltd"`,
+ },
+ });
+ } catch {
+ return rateLimit.sendResponse({ error: "File not found" }, 404);
+ }
+}
diff --git a/backend/src/app/mii/[id]/image/route.ts b/backend/src/app/mii/[id]/image/route.ts
index 21b1782..2b7784c 100644
--- a/backend/src/app/mii/[id]/image/route.ts
+++ b/backend/src/app/mii/[id]/image/route.ts
@@ -12,8 +12,8 @@ import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({
type: z
- .enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], {
- message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'",
+ .enum(["mii", "qr-code", "features", "facepaint", "image0", "image1", "image2", "metadata"], {
+ message: "Image type must be either 'mii', 'qr-code', 'features', 'facepaint', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});
diff --git a/frontend/public/tutorial/switch/adding-mii/step1.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step1.jpg
similarity index 100%
rename from frontend/public/tutorial/switch/adding-mii/step1.jpg
rename to frontend/public/tutorial/switch/adding-mii/manual/step1.jpg
diff --git a/frontend/public/tutorial/switch/adding-mii/step2.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step2.jpg
similarity index 100%
rename from frontend/public/tutorial/switch/adding-mii/step2.jpg
rename to frontend/public/tutorial/switch/adding-mii/manual/step2.jpg
diff --git a/frontend/public/tutorial/switch/adding-mii/step3.png b/frontend/public/tutorial/switch/adding-mii/manual/step3.png
similarity index 100%
rename from frontend/public/tutorial/switch/adding-mii/step3.png
rename to frontend/public/tutorial/switch/adding-mii/manual/step3.png
diff --git a/frontend/public/tutorial/switch/adding-mii/step4.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step4.jpg
similarity index 100%
rename from frontend/public/tutorial/switch/adding-mii/step4.jpg
rename to frontend/public/tutorial/switch/adding-mii/manual/step4.jpg
diff --git a/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png b/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png
new file mode 100644
index 0000000..1471fbd
Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png differ
diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg b/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg
new file mode 100644
index 0000000..a7bacef
Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg differ
diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step2.png b/frontend/public/tutorial/switch/adding-mii/modded/step2.png
new file mode 100644
index 0000000..ea36cd9
Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step2.png differ
diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg b/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg
new file mode 100644
index 0000000..9cad088
Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg differ
diff --git a/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png b/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png
new file mode 100644
index 0000000..3a05302
Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png differ
diff --git a/frontend/src/components/dropzone.tsx b/frontend/src/components/dropzone.tsx
index cd0c5be..4d67715 100644
--- a/frontend/src/components/dropzone.tsx
+++ b/frontend/src/components/dropzone.tsx
@@ -1,49 +1,48 @@
-import { type ReactNode, useState } from "react";
-import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
-import { Icon } from "@iconify/react";
-
-interface Props {
- onDrop: (acceptedFiles: FileWithPath[]) => void;
- options?: DropzoneOptions;
- children?: ReactNode;
-}
-
-export default function Dropzone({ onDrop, options, children }: Props) {
- const [isDraggingOver, setIsDraggingOver] = useState(false);
-
- const handleDrop = (acceptedFiles: FileWithPath[]) => {
- setIsDraggingOver(false);
- onDrop(acceptedFiles);
- };
-
- const { getRootProps, getInputProps } = useDropzone({
- onDrop: handleDrop,
- maxFiles: 3,
- accept: {
- "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
- },
- ...options,
- });
-
- return (
-
setIsDraggingOver(true)}
- onDragLeave={() => setIsDraggingOver(false)}
- className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
- isDraggingOver && "scale-105 brightness-90 shadow-xl"
- }`}
- >
- {/* Used to transition from border-dashed to border-solid */}
-
-
-
1 : false })} />
-
- {children}
-
- );
-}
+import { type ReactNode, useState } from "react";
+import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
+import { Icon } from "@iconify/react";
+
+interface Props {
+ type?: "file" | "image";
+ onDrop: (acceptedFiles: FileWithPath[]) => void;
+ options?: DropzoneOptions;
+ children?: ReactNode;
+}
+
+export default function Dropzone({ type = "image", onDrop, options, children }: Props) {
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
+
+ const handleDrop = (acceptedFiles: FileWithPath[]) => {
+ setIsDraggingOver(false);
+ onDrop(acceptedFiles);
+ };
+
+ const { getRootProps, getInputProps } = useDropzone({
+ onDrop: handleDrop,
+ maxFiles: 3,
+ accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] },
+ ...options,
+ });
+
+ return (
+ setIsDraggingOver(true)}
+ onDragLeave={() => setIsDraggingOver(false)}
+ className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
+ isDraggingOver && "scale-105 brightness-90 shadow-xl"
+ }`}
+ >
+ {/* Used to transition from border-dashed to border-solid */}
+
+
+
1 : false })} />
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/image-viewer.tsx b/frontend/src/components/image-viewer.tsx
index c05257e..e927d20 100644
--- a/frontend/src/components/image-viewer.tsx
+++ b/frontend/src/components/image-viewer.tsx
@@ -1,165 +1,165 @@
-import { useEffect, useState } from "react";
-import { createPortal } from "react-dom";
-import useEmblaCarousel from "embla-carousel-react";
-import { Icon } from "@iconify/react";
-
-interface Props {
- src: string;
- alt: string;
- width: number;
- height: number;
- className?: string;
- images?: string[];
-}
-
-export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
- const [isOpen, setIsOpen] = useState(false);
- const [isVisible, setIsVisible] = useState(false);
-
- const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
- const [selectedIndex, setSelectedIndex] = useState(0);
- const [scrollSnaps, setScrollSnaps] = useState([]);
-
- const close = () => {
- setIsVisible(false);
- setTimeout(() => {
- setIsOpen(false);
- }, 300);
- };
-
- useEffect(() => {
- if (isOpen) {
- // slight delay to trigger animation
- setTimeout(() => setIsVisible(true), 10);
- }
- }, [isOpen]);
-
- useEffect(() => {
- if (!emblaApi) return;
-
- // Keep order of images whilst opening at src prop
- const index = images.indexOf(src);
- if (index !== -1) {
- emblaApi.scrollTo(index, true);
- setSelectedIndex(index);
- }
-
- setScrollSnaps(emblaApi.scrollSnapList());
- emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
- }, [emblaApi, images, src]);
-
- // Handle keyboard events
- useEffect(() => {
- if (!isOpen || !emblaApi) return;
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "ArrowLeft") emblaApi.scrollPrev();
- else if (event.key === "ArrowRight") emblaApi.scrollNext();
- else if (event.key === "Escape") close();
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- };
- }, [isOpen, emblaApi]);
-
- const imagesMap = images.length === 0 ? [src] : images;
-
- return (
- <>
- {/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
- setIsOpen(true)} className={`cursor-pointer ${className}`} />
-
- {isOpen &&
- createPortal(
-
-
-
-
-
-
-
-
-
- {imagesMap.map((image, index) => (
-
-
-
- ))}
-
-
-
- {images.length > 1 && (
- <>
- {/* Carousel counter */}
-
- {selectedIndex + 1} / {images.length}
-
-
- {/* Carousel buttons */}
- {/* Prev button */}
-
emblaApi?.scrollPrev()}
- className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
- >
-
-
- {/* Next button */}
-
emblaApi?.scrollNext()}
- className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
- >
-
-
-
- {/* Carousel snaps */}
-
- {scrollSnaps.map((_, index) => (
- emblaApi?.scrollTo(index)}
- className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
- />
- ))}
-
- >
- )}
-
,
- document.body,
- )}
- >
- );
-}
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import useEmblaCarousel from "embla-carousel-react";
+import { Icon } from "@iconify/react";
+
+interface Props {
+ src: string;
+ alt: string;
+ width: number;
+ height: number;
+ className?: string;
+ images?: string[];
+}
+
+export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+
+ const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [scrollSnaps, setScrollSnaps] = useState([]);
+
+ const close = () => {
+ setIsVisible(false);
+ setTimeout(() => {
+ setIsOpen(false);
+ }, 300);
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ // slight delay to trigger animation
+ setTimeout(() => setIsVisible(true), 10);
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!emblaApi) return;
+
+ // Keep order of images whilst opening at src prop
+ const index = images.indexOf(src);
+ if (index !== -1) {
+ emblaApi.scrollTo(index, true);
+ setSelectedIndex(index);
+ }
+
+ setScrollSnaps(emblaApi.scrollSnapList());
+ emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
+ }, [emblaApi, images, src]);
+
+ // Handle keyboard events
+ useEffect(() => {
+ if (!isOpen || !emblaApi) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "ArrowLeft") emblaApi.scrollPrev();
+ else if (event.key === "ArrowRight") emblaApi.scrollNext();
+ else if (event.key === "Escape") close();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isOpen, emblaApi]);
+
+ const imagesMap = images.length === 0 ? [src] : images;
+
+ return (
+ <>
+ {/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
+ setIsOpen(true)} className={`cursor-pointer ${className}`} />
+
+ {isOpen &&
+ createPortal(
+
+
+
+
+
+
+
+
+
+ {imagesMap.map((image, index) => (
+
+
+
+ ))}
+
+
+
+ {images.length > 1 && (
+ <>
+ {/* Carousel counter */}
+
+ {selectedIndex + 1} / {images.length}
+
+
+ {/* Carousel buttons */}
+ {/* Prev button */}
+
emblaApi?.scrollPrev()}
+ className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
+ >
+
+
+ {/* Next button */}
+
emblaApi?.scrollNext()}
+ className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
+ >
+
+
+
+ {/* Carousel snaps */}
+
+ {scrollSnaps.map((_, index) => (
+ emblaApi?.scrollTo(index)}
+ className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
+ />
+ ))}
+
+ >
+ )}
+
,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/mii/instructions.tsx b/frontend/src/components/mii/instructions.tsx
index bd0d4b1..65c1239 100644
--- a/frontend/src/components/mii/instructions.tsx
+++ b/frontend/src/components/mii/instructions.tsx
@@ -1,254 +1,254 @@
-import type { ReactNode } from "react";
-import DatingPreferencesViewer from "./dating-preferences";
-import PersonalityViewer from "./personality-viewer";
-
-import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
-
-interface Props {
- instructions: Partial;
-}
-
-interface SectionProps {
- name: string;
- instructions: Partial;
- children?: ReactNode;
- isSubSection?: boolean;
-}
-
-const ORDINAL_SUFFIXES: Record = {
- one: "st",
- two: "nd",
- few: "rd",
- other: "th",
-};
-const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
-
-function not(value: any) {
- return value !== undefined && value !== null;
-}
-
-function numberValue(value: number, cutoff: number = 25) {
- return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
-}
-
-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 | undefined | null }) {
- if (color === undefined || color === null) return null;
- if (color <= 7) {
- return (
-
-
- Color menu on left,
-
- );
- }
- if (color >= 108) {
- return (
-
-
- Outside color menu,
-
- );
- }
-
- return (
-
-
- Color menu on right,
-
- );
-}
-
-interface TableCellProps {
- label: string;
- children: React.ReactNode;
-}
-
-function TableCell({ label, children }: TableCellProps) {
- return (
-
- {label}
- {children}
-
- );
-}
-
-function Section({ name, instructions, children, isSubSection }: SectionProps) {
- if (typeof instructions !== "object" || !instructions) return null;
-
- const color = "color" in instructions ? instructions.color : undefined;
- const height = "height" in instructions ? instructions.height : undefined;
- const distance = "distance" in instructions ? instructions.distance : undefined;
- const rotation = "rotation" in instructions ? instructions.rotation : undefined;
- const size = "size" in instructions ? instructions.size : undefined;
- const stretch = "stretch" in instructions ? instructions.stretch : undefined;
-
- return (
-
-
{name}
-
-
-
- {not(color) && (
-
-
-
- )}
- {not(height) && {numberValue(height!, 0)} }
- {not(distance) && {numberValue(distance!, 0)} }
- {not(rotation) && {numberValue(rotation!, 0)} }
- {not(size) && {numberValue(size!, 0)} }
- {not(stretch) && {numberValue(stretch!, 0)} }
-
- {children}
-
-
-
- );
-}
-
-export default function MiiInstructions({ instructions }: Props) {
- if (Object.keys(instructions).length === 0) return null;
- const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
-
- return (
- <>
- {head && (
-
- {not(head.skinColor) && (
-
-
-
- )}
-
- )}
- {hair && (
-
- {not(hair.subColor) && (
-
-
-
- )}
- {not(hair.subColor2) && (
-
-
-
- )}
- {not(hair.style) && {hair.style} }
- {not(hair.isFlipped) && {hair.isFlipped ? "Yes" : "No"} }
-
- )}
- {eyebrows && }
- {eyes && (
-
- )}
- {nose && }
- {lips && (
-
- {not(lips.hasLipstick) && {lips.hasLipstick ? "Yes" : "No"} }
-
- )}
- {ears && }
- {glasses && (
-
- {not(glasses.ringColor) && (
-
-
-
- )}
- {not(glasses.shadesColor) && (
-
-
-
- )}
-
- )}
- {other && (
-
-
-
-
-
- {other.moustache && other.moustache.isFlipped && {other.moustache.isFlipped ? "Yes" : "No"} }
-
-
-
-
-
-
- )}
-
- {(height || weight || datingPreferences || voice || personality) && (
-
-
Misc
-
-
-
- {not(height) && {numberValue(height!, 64)} }
- {not(weight) && {numberValue(weight!, 64)} }
-
-
- {birthday && (
-
-
Birthday
-
-
- {not(birthday.day) && {birthday.day} }
- {not(birthday.month) && {birthday.month} }
- {not(birthday.age) && {birthday.age} }
- {not(birthday.dontAge) && {birthday.dontAge ? "Yes" : "No"} }
-
-
-
- )}
- {voice && (
-
-
Voice
-
-
- {not(voice.speed) && {numberValue(voice.speed!, 25)} }
- {not(voice.pitch) && {numberValue(voice.pitch!, 25)} }
- {not(voice.depth) && {numberValue(voice.depth!, 25)} }
- {not(voice.delivery) && {numberValue(voice.delivery!, 25)} }
- {not(voice.tone) && {voice.tone} }
-
-
-
- )}
- {datingPreferences && (
-
-
Dating Preferences
-
-
-
-
- )}
- {personality && (
-
- )}
-
- )}
- >
- );
-}
+import type { ReactNode } from "react";
+import DatingPreferencesViewer from "./dating-preferences";
+import PersonalityViewer from "./personality-viewer";
+
+import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
+
+interface Props {
+ instructions: Partial;
+}
+
+interface SectionProps {
+ name: string;
+ instructions: Partial;
+ children?: ReactNode;
+ isSubSection?: boolean;
+}
+
+const ORDINAL_SUFFIXES: Record = {
+ one: "st",
+ two: "nd",
+ few: "rd",
+ other: "th",
+};
+const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
+
+function not(value: any) {
+ return value !== undefined && value !== null;
+}
+
+function numberValue(value: number, cutoff: number = 25) {
+ return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
+}
+
+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 | undefined | null }) {
+ if (color === undefined || color === null) return null;
+ if (color <= 7) {
+ return (
+
+
+ Color menu on left,
+
+ );
+ }
+ if (color >= 108) {
+ return (
+
+
+ Outside color menu,
+
+ );
+ }
+
+ return (
+
+
+ Color menu on right,
+
+ );
+}
+
+interface TableCellProps {
+ label: string;
+ children: React.ReactNode;
+}
+
+function TableCell({ label, children }: TableCellProps) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function Section({ name, instructions, children, isSubSection }: SectionProps) {
+ if (typeof instructions !== "object" || !instructions) return null;
+
+ const color = "color" in instructions ? instructions.color : undefined;
+ const height = "height" in instructions ? instructions.height : undefined;
+ const distance = "distance" in instructions ? instructions.distance : undefined;
+ const rotation = "rotation" in instructions ? instructions.rotation : undefined;
+ const size = "size" in instructions ? instructions.size : undefined;
+ const stretch = "stretch" in instructions ? instructions.stretch : undefined;
+
+ return (
+
+
{name}
+
+
+
+ {not(color) && (
+
+
+
+ )}
+ {not(height) && {numberValue(height!, 0)} }
+ {not(distance) && {numberValue(distance!, 0)} }
+ {not(rotation) && {numberValue(rotation!, 0)} }
+ {not(size) && {numberValue(size!, 0)} }
+ {not(stretch) && {numberValue(stretch!, 0)} }
+
+ {children}
+
+
+
+ );
+}
+
+export default function MiiInstructions({ instructions }: Props) {
+ if (Object.keys(instructions).length === 0) return null;
+ const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
+
+ return (
+ <>
+ {head && (
+
+ {not(head.skinColor) && (
+
+
+
+ )}
+
+ )}
+ {hair && (
+
+ {not(hair.subColor) && (
+
+
+
+ )}
+ {not(hair.subColor2) && (
+
+
+
+ )}
+ {not(hair.style) && {hair.style} }
+ {not(hair.isFlipped) && {hair.isFlipped ? "Yes" : "No"} }
+
+ )}
+ {eyebrows && }
+ {eyes && (
+
+ )}
+ {nose && }
+ {lips && (
+
+ {not(lips.hasLipstick) && {lips.hasLipstick ? "Yes" : "No"} }
+
+ )}
+ {ears && }
+ {glasses && (
+
+ {not(glasses.ringColor) && (
+
+
+
+ )}
+ {not(glasses.shadesColor) && (
+
+
+
+ )}
+
+ )}
+ {other && (
+
+
+
+
+
+ {other.moustache && other.moustache.isFlipped && {other.moustache.isFlipped ? "Yes" : "No"} }
+
+
+
+
+
+
+ )}
+
+ {(height || weight || datingPreferences || voice || personality) && (
+
+
Misc
+
+
+
+ {not(height) && {numberValue(height!, 64)} }
+ {not(weight) && {numberValue(weight!, 64)} }
+
+
+ {birthday && (
+
+
Birthday
+
+
+ {not(birthday.day) && {birthday.day} }
+ {not(birthday.month) && {birthday.month} }
+ {not(birthday.age) && {birthday.age} }
+ {not(birthday.dontAge) && {birthday.dontAge ? "Yes" : "No"} }
+
+
+
+ )}
+ {voice && (
+
+
Voice
+
+
+ {not(voice.speed) && {numberValue(voice.speed!, 25)} }
+ {not(voice.pitch) && {numberValue(voice.pitch!, 25)} }
+ {not(voice.depth) && {numberValue(voice.depth!, 25)} }
+ {not(voice.delivery) && {numberValue(voice.delivery!, 25)} }
+ {not(voice.tone) && {voice.tone} }
+
+
+
+ )}
+ {datingPreferences && (
+
+
Dating Preferences
+
+
+
+
+ )}
+ {personality && (
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/mii/list/other-filters.tsx b/frontend/src/components/mii/list/other-filters.tsx
index 3da9ebc..f3a124a 100644
--- a/frontend/src/components/mii/list/other-filters.tsx
+++ b/frontend/src/components/mii/list/other-filters.tsx
@@ -9,9 +9,27 @@ export default function OtherFilters() {
const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
+ const [hasShareMiiFile, setHasShareMiiFile] = useState((searchParams.get("sharemii") as unknown as boolean) ?? false);
const [allowCopying, setAllowCopying] = useState((searchParams.get("allowCopying") as unknown as boolean) ?? false);
const [quarantined, setQuarantined] = useState((searchParams.get("quarantined") as unknown as boolean) ?? false);
+ const handleChangeHasShareMiiFile = (e: ChangeEvent) => {
+ setHasShareMiiFile(e.target.checked);
+
+ const params = new URLSearchParams(searchParams);
+ params.set("page", "1");
+
+ if (!hasShareMiiFile) {
+ params.set("isFromSaveFile", "true");
+ } else {
+ params.delete("isFromSaveFile");
+ }
+
+ startTransition(() => {
+ navigate(`?${params.toString()}`);
+ });
+ };
+
const handleChangeAllowCopying = (e: ChangeEvent) => {
setAllowCopying(e.target.checked);
@@ -59,6 +77,14 @@ export default function OtherFilters() {
+ {platform !== "THREE_DS" && (
+
+
+ Has ShareMii File
+
+
+
+ )}
{showAllowCopying && (
diff --git a/frontend/src/components/submit-form/switch-file-upload.tsx b/frontend/src/components/submit-form/switch-file-upload.tsx
index f533ca1..986dc2e 100644
--- a/frontend/src/components/submit-form/switch-file-upload.tsx
+++ b/frontend/src/components/submit-form/switch-file-upload.tsx
@@ -1,73 +1,83 @@
-import { useCallback, useState } from "react";
-import { type FileWithPath } from "react-dropzone";
-import { Icon } from "@iconify/react";
-import Dropzone from "../dropzone";
-import Camera from "./camera";
-import ImageEditorPortrait from "./image-editor";
-
-interface Props {
- text: string;
- forceCrop?: boolean;
- image?: string | undefined;
- setImage: (value: string | undefined) => void;
-}
-
-export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
- const [isCameraOpen, setIsCameraOpen] = useState(false);
- const [isCropOpen, setIsCropOpen] = 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);
- if (forceCrop) setIsCropOpen(true);
- };
- reader.readAsDataURL(file);
- },
- [setImage],
- );
-
- return (
-
-
-
- {!image ? (
- <>
- Drag and drop {text}
-
- or click to open
- >
- ) : (
- "Uploaded!"
- )}
-
-
-
-
or
-
-
- setIsCameraOpen(true)} className="pill button gap-2">
-
- Use your camera
-
- setIsCropOpen(true)} className="pill button gap-2">
-
- Edit Image
-
-
-
-
{
- if (forceCrop) setIsCropOpen(true);
- }}
- />
-
-
- );
-}
+import { useCallback, useState } from "react";
+import { type FileWithPath } from "react-dropzone";
+import { Icon } from "@iconify/react";
+import Dropzone from "../dropzone";
+import Camera from "./camera";
+import ImageEditorPortrait from "./image-editor";
+
+interface Props {
+ text: string;
+ type?: "file" | "image";
+ forceCrop?: boolean;
+ file?: string | File | undefined;
+ setFile?: (value: File | undefined) => void;
+ image?: string | undefined;
+ setImage?: (value: string | undefined) => void;
+}
+
+export default function SwitchFileUpload({ text, type = "image", forceCrop, file, setFile, image, setImage }: Props) {
+ const [isCameraOpen, setIsCameraOpen] = useState(false);
+ const [isCropOpen, setIsCropOpen] = useState(false);
+
+ const handleDrop = useCallback(
+ (acceptedFiles: FileWithPath[]) => {
+ const file = acceptedFiles[0];
+ if (type === "file") {
+ setFile!(file);
+ } else {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setImage!(event.target!.result as string);
+ if (forceCrop) setIsCropOpen(true);
+ };
+ reader.readAsDataURL(file);
+ }
+ },
+ [setFile, setImage],
+ );
+
+ return (
+
+
+
+ {!file && !image ? (
+ <>
+ Drag and drop {text}
+
+ or click to open
+ >
+ ) : (
+ "Uploaded!"
+ )}
+
+
+
+ {type === "image" && (
+ <>
+
or
+
+
+ setIsCameraOpen(true)} className="pill button gap-2">
+
+ Use your camera
+
+ setIsCropOpen(true)} className="pill button gap-2">
+
+ Edit Image
+
+
+
+
{
+ if (forceCrop) setIsCropOpen(true);
+ }}
+ />
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/tutorial/index.tsx b/frontend/src/components/tutorial/index.tsx
index 7aad063..36a970b 100644
--- a/frontend/src/components/tutorial/index.tsx
+++ b/frontend/src/components/tutorial/index.tsx
@@ -1,205 +1,211 @@
-import { useEffect, useState } from "react";
-import useEmblaCarousel from "embla-carousel-react";
-import { Icon } from "@iconify/react";
-import confetti from "canvas-confetti";
-
-interface Slide {
- // step is never used, undefined is assumed as a step
- type?: "start" | "step" | "finish";
- text?: string;
- imageSrc?: string;
-}
-
-interface Tutorial {
- title: string;
- thumbnail?: string;
- hint?: string;
- steps: Slide[];
-}
-
-interface Props {
- tutorials: Tutorial[];
- isOpen: boolean;
- setIsOpen: React.Dispatch>;
-}
-
-export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
- const [isVisible, setIsVisible] = useState(false);
-
- const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
- const [selectedIndex, setSelectedIndex] = useState(0);
-
- // Build index map
- const slides: Array = [];
- const startSlides: Record = {};
-
- tutorials.forEach((tutorial) => {
- tutorial.steps.forEach((slide) => {
- if (slide.type === "start") {
- startSlides[tutorial.title] = slides.length;
- }
- slides.push({ ...slide, tutorialTitle: tutorial.title });
- });
- });
-
- const currentSlide = slides[selectedIndex];
- const isStartingPage = currentSlide?.type === "start";
-
- useEffect(() => {
- if (currentSlide.type !== "finish") return;
-
- const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
- const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
-
- setTimeout(() => {
- confetti({
- ...defaults,
- particleCount: 500,
- origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
- });
- confetti({
- ...defaults,
- particleCount: 500,
- origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
- });
- }, 300);
- }, [currentSlide]);
-
- const close = () => {
- setIsVisible(false);
- setTimeout(() => {
- setIsOpen(false);
- setSelectedIndex(0);
- }, 300);
- };
-
- const goToTutorial = (tutorialTitle: string) => {
- if (!emblaApi) return;
- const index = startSlides[tutorialTitle];
-
- // Jump to next starting slide then transition to actual tutorial
- emblaApi.scrollTo(index, true);
- emblaApi.scrollTo(index + 1);
- };
-
- useEffect(() => {
- if (isOpen) {
- // slight delay to trigger animation
- setTimeout(() => setIsVisible(true), 10);
- }
- }, [isOpen]);
-
- useEffect(() => {
- if (!emblaApi) return;
- emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
- }, [emblaApi]);
-
- return (
-
-
-
-
-
-
Tutorial
-
-
-
-
-
-
-
-
- {slides.map((slide, index) => (
-
- {slide.type === "start" ? (
- <>
- {/* Separator */}
-
-
- Pick a tutorial
-
-
-
-
- {tutorials.map((tutorial, tutorialIndex) => (
-
goToTutorial(tutorial.title)}
- aria-label={tutorial.title + " tutorial"}
- className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
- >
-
- {tutorial.title}
- {/* Set opacity to 0 to keep height the same with other tutorials */}
- {tutorial.hint || "placeholder"}
-
- ))}
-
- >
- ) : slide.type === "finish" ? (
-
-
-
Yatta! You did it!
-
- ) : (
- <>
-
{slide.text}
-
-
- >
- )}
-
- ))}
-
-
-
- {/* Arrows */}
-
- emblaApi?.scrollPrev()}
- disabled={isStartingPage}
- className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
- aria-label="Scroll Carousel Left"
- >
-
-
-
- {/* Only show tutorial name on step slides */}
-
- {currentSlide?.tutorialTitle}
-
-
- emblaApi?.scrollNext()}
- disabled={isStartingPage}
- className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
- aria-label="Scroll Carousel Right"
- >
-
-
-
-
-
-
- );
-}
+import { useEffect, useState } from "react";
+import useEmblaCarousel from "embla-carousel-react";
+import { Icon } from "@iconify/react";
+import confetti from "canvas-confetti";
+
+interface Slide {
+ type?: "start" | "step" | "finish"; // step is never specified, undefined is assumed as step
+ text?: string;
+ link?: string;
+ imageSrc?: string;
+}
+
+interface Tutorial {
+ title: string;
+ thumbnail?: string;
+ hint?: string;
+ steps: Slide[];
+}
+
+interface Props {
+ tutorials: Tutorial[];
+ isOpen: boolean;
+ setIsOpen: React.Dispatch>;
+}
+
+export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
+ const [isVisible, setIsVisible] = useState(false);
+
+ const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ // Build index map
+ const slides: Array = [];
+ const startSlides: Record = {};
+
+ tutorials.forEach((tutorial) => {
+ tutorial.steps.forEach((slide) => {
+ if (slide.type === "start") {
+ startSlides[tutorial.title] = slides.length;
+ }
+ slides.push({ ...slide, tutorialTitle: tutorial.title });
+ });
+ });
+
+ const currentSlide = slides[selectedIndex];
+ const isStartingPage = currentSlide?.type === "start";
+
+ useEffect(() => {
+ if (currentSlide.type !== "finish") return;
+
+ const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
+ const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
+
+ setTimeout(() => {
+ confetti({
+ ...defaults,
+ particleCount: 500,
+ origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
+ });
+ confetti({
+ ...defaults,
+ particleCount: 500,
+ origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
+ });
+ }, 300);
+ }, [currentSlide]);
+
+ const close = () => {
+ setIsVisible(false);
+ setTimeout(() => {
+ setIsOpen(false);
+ setSelectedIndex(0);
+ }, 300);
+ };
+
+ const goToTutorial = (tutorialTitle: string) => {
+ if (!emblaApi) return;
+ const index = startSlides[tutorialTitle];
+
+ // Jump to next starting slide then transition to actual tutorial
+ emblaApi.scrollTo(index, true);
+ emblaApi.scrollTo(index + 1);
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ // slight delay to trigger animation
+ setTimeout(() => setIsVisible(true), 10);
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!emblaApi) return;
+ emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
+ }, [emblaApi]);
+
+ return (
+
+
+
+
+
+
Tutorial
+
+
+
+
+
+
+
+
+ {slides.map((slide, index) => (
+
+ {slide.type === "start" ? (
+ <>
+ {/* Separator */}
+
+
+ Pick a tutorial
+
+
+
+
+ {tutorials.map((tutorial, tutorialIndex) => (
+
goToTutorial(tutorial.title)}
+ aria-label={tutorial.title + " tutorial"}
+ className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
+ >
+
+ {tutorial.title}
+ {/* Set opacity to 0 to keep height the same with other tutorials */}
+ {tutorial.hint || "placeholder"}
+
+ ))}
+
+ >
+ ) : slide.type === "finish" ? (
+
+
+
Yatta! You did it!
+
+ ) : (
+ <>
+ {slide.link ? (
+
+ {slide.text}
+
+ ) : (
+
{slide.text}
+ )}
+
+
+ >
+ )}
+
+ ))}
+
+
+
+ {/* Arrows */}
+
+ emblaApi?.scrollPrev()}
+ disabled={isStartingPage}
+ className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
+ aria-label="Scroll Carousel Left"
+ >
+
+
+
+ {/* Only show tutorial name on step slides */}
+
+ {currentSlide?.tutorialTitle}
+
+
+ emblaApi?.scrollNext()}
+ disabled={isStartingPage}
+ className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
+ aria-label="Scroll Carousel Right"
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/tutorial/switch-add-mii.tsx b/frontend/src/components/tutorial/switch-add-mii.tsx
index b46faf8..97f5497 100644
--- a/frontend/src/components/tutorial/switch-add-mii.tsx
+++ b/frontend/src/components/tutorial/switch-add-mii.tsx
@@ -1,54 +1,78 @@
-import { useState } from "react";
-import { createPortal } from "react-dom";
-import { Icon } from "@iconify/react";
-import Tutorial from ".";
-
-export default function SwitchAddMiiTutorialButton() {
- const [isOpen, setIsOpen] = useState(false);
-
- return (
- <>
- setIsOpen(true)} className="text-3xl cursor-pointer">
-
- Tutorial
-
-
- {isOpen &&
- createPortal(
- ,
- document.body,
- )}
- >
- );
-}
+import { useState } from "react";
+import { createPortal } from "react-dom";
+import { Icon } from "@iconify/react";
+import Tutorial from ".";
+
+export default function SwitchAddMiiTutorialButton() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+ setIsOpen(true)} className="text-3xl cursor-pointer">
+
+ Tutorial
+
+
+ {isOpen &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/tutorial/switch-submit.tsx b/frontend/src/components/tutorial/switch-submit.tsx
index fa4623a..42bd1ee 100644
--- a/frontend/src/components/tutorial/switch-submit.tsx
+++ b/frontend/src/components/tutorial/switch-submit.tsx
@@ -1,48 +1,67 @@
-import { useState } from "react";
-import { createPortal } from "react-dom";
-import Tutorial from ".";
-
-export default function SwitchSubmitTutorialButton() {
- const [isOpen, setIsOpen] = useState(false);
-
- return (
- <>
- setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
- How to?
-
-
- {isOpen &&
- createPortal(
- ,
- document.body,
- )}
- >
- );
-}
+import { useState } from "react";
+import { createPortal } from "react-dom";
+import Tutorial from ".";
+
+interface Props {
+ type: "savedata" | "manual";
+}
+
+export default function SwitchSubmitTutorialButton({ type }: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+ setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
+ How to?
+
+
+ {isOpen &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/edit.tsx b/frontend/src/pages/edit.tsx
index 1db227a..ad3bfd1 100644
--- a/frontend/src/pages/edit.tsx
+++ b/frontend/src/pages/edit.tsx
@@ -423,7 +423,7 @@ export default function EditMiiPage() {
-
+
You must upload a screenshot of the features, check tutorial on how.
@@ -457,7 +457,7 @@ export default function EditMiiPage() {
-
+
>
)}
diff --git a/frontend/src/pages/mii.tsx b/frontend/src/pages/mii.tsx
index c3d21e3..b62f2b6 100644
--- a/frontend/src/pages/mii.tsx
+++ b/frontend/src/pages/mii.tsx
@@ -135,13 +135,47 @@ export default function MiiPage() {
/>
) : (
-
+ <>
+
+
+ {mii.isFromSaveFile && (
+ <>
+
+
+ Face Paint Texture
+
+
+
+
+
+
+ >
+ )}
+ >
)}
@@ -340,9 +374,15 @@ export default function MiiPage() {
{/* Buttons */}
-
+
+ {mii.isFromSaveFile && (
+
+
+ Download
+
+ )}
@@ -353,11 +393,16 @@ export default function MiiPage() {
{/* Instructions */}
{mii.platform === "SWITCH" && (
-
+
Instructions
+
+ All instructions are based off of the default Male Mii.
+
+ {mii.isFromSaveFile && "If you're on modded/emulator, you can download the .ltd file above."}
+
{mii.youtubeId && (
-
@@ -209,7 +223,6 @@ export default function SubmitPage() {
Share your creation for others to see.
- {/* Separator */}
Info
@@ -222,33 +235,23 @@ export default function SubmitPage() {
Platform
- {/* Animated indicator */}
- {/* TODO: maybe change width as part of animation? */}
-
- {/* Switch button */}
+ />
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!"
- }`}
+ 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!"}`}
>
Switch
-
- {/* 3DS button */}
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!"
- }`}
+ 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!"}`}
>
3DS
@@ -307,43 +310,34 @@ export default function SubmitPage() {
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"
- }`}
+ 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"}`}
>
-
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"
- }`}
+ 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"}`}
>
-
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"
- }`}
+ 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"}`}
>
- {/* Makeup (switch only) */}
+ {/* Makeup (switch only) — unchanged from base */}
Face Paint
-
{[
{ value: "FULL", label: "Full", desc: "Most of the face/features are covered", color: "pink" },
@@ -365,9 +359,34 @@ export default function SubmitPage() {
- {/* (Switch Only) Mii Screenshots */}
+ {/* (Switch only) Choose a Way */}
- {/* Separator */}
+
+
+ Choose a Way
+
+
+
+ setWay("savedata")}
+ type="button"
+ className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "savedata" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
+ >
+ ShareMii file (.ltd) (Modded)
+
+ setWay("manual")}
+ type="button"
+ className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "manual" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
+ >
+ Manual
+
+
+
To see a tutorial, select a method above and click the 'How to?' buttons that appear.
+
+
+ {/* (Switch only) Mii Screenshots */}
+
Mii Screenshots
@@ -396,7 +415,7 @@ export default function SubmitPage() {
{/* Step 2 - Features */}
-
+
2
@@ -415,12 +434,27 @@ export default function SubmitPage() {
+
+
A tutorial on how to screenshot the features is above.
-
-
+
-
A tutorial on how to screenshot the features is above.
+ {/* (Switch only) ShareMii file upload */}
+
+
+
+ ShareMii File
+
+
+
+
+
+
Only the v3 format is supported, please make sure ShareMii is up to date.
+
+ Unfortunately, at this time we can't automatically generate instructions from a .ltd file.
+
+
{/* (3DS only) QR code scanning */}
@@ -430,33 +464,27 @@ export default function SubmitPage() {
QR Code
-
or
-
setIsQrScannerOpen(true)} className="pill button gap-2">
Use your camera
-
-
For emulators, aes_keys.txt is required.
- {/* (Switch only) Mii instructions */}
-
+ {/* (Switch only) Mii Instructions */}
+
Mii Instructions
-
- {/* YouTube */}
YouTube Video
@@ -476,40 +504,37 @@ export default function SubmitPage() {
}}
/>
-
-
+
- Mii editor may be inaccurate. Instructions are REALLY recommended, but you do not have to add every instruction.
+ Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
{/* Custom images selector */}
-
-
-
Custom images
-
+
+
+
+ Custom images
+
+
+
+
+
+ Drag and drop your images here
+
+ or click to open
+
+
+
Animated images currently not supported.
+
+
-
-
-
- Drag and drop your images here
-
- or click to open
-
-
-
-
Animated images currently not supported.
-
-
-
-
{error && Error: {error} }
-
diff --git a/package.json b/package.json
index f66378c..d28fe6f 100644
--- a/package.json
+++ b/package.json
@@ -9,5 +9,5 @@
"keywords": [],
"author": "",
"license": "ISC",
- "packageManager": "pnpm@10.30.3"
+ "packageManager": "pnpm@10.33.2"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30d40a8..c510754 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,6 +25,9 @@ importers:
bit-buffer:
specifier: ^0.3.0
version: 0.3.0
+ charinfo-ex:
+ specifier: ^0.0.5
+ version: 0.0.5
dayjs:
specifier: ^1.11.20
version: 1.11.20
@@ -219,6 +222,15 @@ importers:
bit-buffer:
specifier: ^0.3.0
version: 0.3.0
+ charinfo-ex:
+ specifier: ^0.0.5
+ version: 0.0.5
+ fzstd:
+ specifier: ^0.1.1
+ version: 0.1.1
+ sharp:
+ specifier: ^0.34.5
+ version: 0.34.5
sjcl-with-all:
specifier: 1.0.8
version: 1.0.8
@@ -1541,6 +1553,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ charinfo-ex@0.0.5:
+ resolution: {integrity: sha512-GQTTcRfLPbrK5lY+fRqAq1mQUIP7H1EpAO3/I6Kv5t7TditmI6NkGg8nWbRP8hb8JKFZe1oJqAumJWfLYTma1w==}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -1998,6 +2013,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ fzstd@0.1.1:
+ resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
+
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -4249,6 +4267,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ charinfo-ex@0.0.5: {}
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -4900,6 +4920,8 @@ snapshots:
functions-have-names@1.2.3: {}
+ fzstd@0.1.1: {}
+
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
diff --git a/shared/package.json b/shared/package.json
index 3b2376e..707eb8f 100644
--- a/shared/package.json
+++ b/shared/package.json
@@ -4,6 +4,9 @@
"dependencies": {
"@2toad/profanity": "^3.3.0",
"bit-buffer": "^0.3.0",
+ "charinfo-ex": "^0.0.5",
+ "fzstd": "^0.1.1",
+ "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"zod": "^4.3.6"
},
diff --git a/shared/src/deswizzle.ts b/shared/src/deswizzle.ts
new file mode 100644
index 0000000..3c083dc
--- /dev/null
+++ b/shared/src/deswizzle.ts
@@ -0,0 +1,133 @@
+// TypeScript implementation of https://github.com/Aclios/pyswizzle/blob/main/src/pyswizzle/pyswizzle.py
+type Grid = Uint8Array[][];
+
+export class BytesDeswizzle {
+ private data: Uint8Array;
+ private deswizzleDataList: [number, 0 | 1][];
+ private readSize: number;
+ private readPerTileCount: number;
+ private tileCount: number;
+ private tilePerWidth: number;
+ private dataReadIdx: number;
+
+ constructor(data: Uint8Array, imSize: [number, number], blockSize: [number, number], bytesPerBlock: number, swizzleMode: number) {
+ this.data = data;
+ const datasize = data.length;
+ const [imWidth, imHeight] = imSize;
+ const [blockWidth, blockHeight] = blockSize;
+
+ const expectedDataSize = Math.floor((imWidth * imHeight) / (blockWidth * blockHeight)) * bytesPerBlock;
+
+ if (expectedDataSize !== datasize)
+ throw new Error(
+ `Error: Invalid data size.\nExpected datasize (according to image and format specifications): ${expectedDataSize}\nActual datasize: ${datasize}`,
+ );
+
+ const tileDatasize = 512 * 2 ** swizzleMode;
+ const tileWidth = Math.floor(64 / bytesPerBlock) * blockWidth;
+ const tileHeight = 8 * blockHeight * 2 ** swizzleMode;
+ this.deswizzleDataList = [
+ [2, 0],
+ [2, 1],
+ [4, 0],
+ [2, 1],
+ [2 ** swizzleMode, 0],
+ ];
+ this.readSize = 16;
+ this.readPerTileCount = 32 * 2 ** swizzleMode;
+
+ if (datasize % tileDatasize !== 0)
+ throw new Error(
+ `Error: Invalid data size. The data size should be a multiple of ${tileDatasize}, while the given datasize is ${datasize}. Height and/or width padding may be required in the original image.`,
+ );
+
+ this.tileCount = Math.floor(datasize / tileDatasize);
+
+ if (imWidth % tileWidth !== 0)
+ throw new Error(`Error: with the current parameters, image width should be a multiple of ${tileWidth}, but the given width is ${imWidth}`);
+ if (imHeight % tileHeight !== 0)
+ throw new Error(`Error: with the current parameters, image height should be a multiple of ${tileHeight}, but the given height is ${imHeight}`);
+
+ this.tilePerWidth = Math.floor(imWidth / tileWidth);
+ this.dataReadIdx = 0;
+ }
+
+ private getTileData(): Grid[] {
+ const arrayList: Grid[] = [];
+ for (let i = 0; i < this.readPerTileCount; i++) {
+ arrayList.push([[this.data.slice(this.dataReadIdx, this.dataReadIdx + this.readSize)]]);
+ this.dataReadIdx += this.readSize;
+ }
+ return arrayList;
+ }
+
+ private concatArrays(arrayList: Grid[], sectionNumber: number, axis: 0 | 1): Grid[] {
+ const newArrayList: Grid[] = [];
+ let idx = 0;
+
+ for (let i = 0; i < Math.floor(arrayList.length / sectionNumber); i++) {
+ const slice = arrayList.slice(idx, idx + sectionNumber);
+ let newGrid: Grid;
+
+ if (axis === 0) {
+ // np.concatenate(..., axis=0)
+ newGrid = [];
+ for (const grid of slice) {
+ newGrid.push(...grid);
+ }
+ } else {
+ // np.concatenate(..., axis=1)
+ newGrid = [];
+ for (let r = 0; r < slice[0].length; r++) {
+ const newRow: Uint8Array[] = [];
+ for (const grid of slice) {
+ newRow.push(...grid[r]);
+ }
+ newGrid.push(newRow);
+ }
+ }
+
+ newArrayList.push(newGrid);
+ idx += sectionNumber;
+ }
+
+ return newArrayList;
+ }
+
+ private deswizzleTile(): Grid {
+ let arrayList = this.getTileData();
+ for (const deswizzleData of this.deswizzleDataList) {
+ arrayList = this.concatArrays(arrayList, deswizzleData[0], deswizzleData[1]);
+ }
+ return arrayList[0];
+ }
+
+ public deswizzle(): Uint8Array {
+ const tileList: Grid[] = [];
+ for (let i = 0; i < this.tileCount; i++) {
+ tileList.push(this.deswizzleTile());
+ }
+
+ const tileListWidthConcat = this.concatArrays(tileList, this.tilePerWidth, 1);
+ const deswizzledGrid = this.concatArrays(tileListWidthConcat, tileListWidthConcat.length, 0)[0];
+
+ // tobytes()
+ const deswizzledData = new Uint8Array(this.data.length);
+ let offset = 0;
+
+ for (const row of deswizzledGrid) {
+ for (const chunk of row) {
+ deswizzledData.set(chunk, offset);
+ offset += chunk.length;
+ }
+ }
+
+ if (deswizzledData.length !== this.data.length) {
+ throw new Error(
+ `An unknown error occurred while deswizzling bytes: output data length is (somehow) different than input data length. Input data: ${this.data.length}, Output data: ${deswizzledData.length}`,
+ );
+ }
+
+ return deswizzledData;
+ }
+}
diff --git a/shared/src/index.ts b/shared/src/index.ts
index 898a9f8..2b55e03 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -2,5 +2,6 @@ export * from "./constants";
export * from "./qr-codes";
export * from "./switch";
export * from "./three-ds-tomodachi-life-mii";
+export * from "./switch-tomodachi-life-mii";
export * from "./utils";
export type { SwitchMiiInstructions, MiiGender, MiiMakeup, MiiPlatform, ReportReason } from "./types";
diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts
index 1845405..fbe3eec 100644
--- a/shared/src/schemas.ts
+++ b/shared/src/schemas.ts
@@ -61,6 +61,7 @@ export const searchSchema = z.object({
platform: z.enum(["THREE_DS", "SWITCH"], { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(["MALE", "FEMALE", "NONBINARY"], { error: "Gender must be either 'MALE', 'FEMALE', or 'NONBINARY' if on Switch platform" }).optional(),
makeup: z.enum(["FULL", "PARTIAL", "NONE"], { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(),
+ isFromSaveFile: z.coerce.boolean({ error: "'isFromSaveFile' must be either true or false" }).optional(),
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
// Pages
diff --git a/shared/src/switch-tomodachi-life-mii.ts b/shared/src/switch-tomodachi-life-mii.ts
new file mode 100644
index 0000000..1a18421
--- /dev/null
+++ b/shared/src/switch-tomodachi-life-mii.ts
@@ -0,0 +1,277 @@
+import { CharInfoEx } from "charinfo-ex";
+import * as fzstd from "fzstd";
+import { BytesDeswizzle } from "./deswizzle";
+
+import { minifyInstructions } from "./switch";
+import { type MiiGender, type SwitchMiiInstructions } from "./types";
+
+export class SwitchTomodachiLifeMii {
+ buffer: ArrayBuffer;
+ data: CharInfoEx;
+
+ datingPreferences: MiiGender[];
+ birthday: { month: number; day: number; age: number; dontAge: boolean };
+ voice: { speed: number; pitch: number; depth: number; delivery: number; tone: number };
+ personality: { movement: number; speech: number; energy: number; thinking: number; overall: number };
+
+ constructor(buffer: ArrayBuffer, data: CharInfoEx) {
+ this.buffer = buffer;
+ this.data = data;
+
+ const view = new DataView(buffer);
+ const bytes = new Uint8Array(buffer);
+ const parse = (index: number): number => view.getUint8(161 + index * 4);
+
+ const age = view.getUint32(0x00e1, true);
+ const year = view.getUint32(0x00d9, true);
+ const dontAge = age !== 0xffffffff;
+
+ this.datingPreferences = (["MALE", "FEMALE", "NONBINARY"] as const).filter((_, i) => bytes[0x01a9 + i] === 1);
+ this.birthday = {
+ month: parse(17),
+ day: parse(15),
+ age: dontAge ? age : new Date().getFullYear() - year,
+ dontAge,
+ };
+ this.voice = {
+ speed: parse(6),
+ pitch: parse(8),
+ depth: parse(5),
+ delivery: Math.max(0, view.getInt8(0xc5)), // why is this an integer??
+ tone: parse(7) + 1,
+ // TODO: add voice preset to instructions type?
+ };
+ this.personality = {
+ movement: parse(4) - 1,
+ speech: parse(2) - 1,
+ energy: parse(1) - 1,
+ thinking: parse(0) - 1,
+ overall: parse(3) - 1,
+ };
+
+ // Validate
+ if (bytes[0x01a9] > 1 || bytes[0x01aa] > 1 || bytes[0x01ab] > 1) throw new Error("Invalid dating preference bytes");
+ if (this.birthday.month < 1 || this.birthday.month > 12) throw new Error("Invalid birthday month");
+ if (this.birthday.day < 1 || this.birthday.day > 31) throw new Error("Invalid birthday day");
+ if (
+ this.personality.movement < 0 ||
+ this.personality.movement > 4 ||
+ this.personality.speech < 0 ||
+ this.personality.speech > 4 ||
+ this.personality.energy < 0 ||
+ this.personality.energy > 4 ||
+ this.personality.thinking < 0 ||
+ this.personality.thinking > 4 ||
+ this.personality.overall < 0 ||
+ this.personality.overall > 4
+ )
+ throw new Error("Invalid personality values");
+ }
+
+ // There's a UGC Texture image but we're ignoring it
+ public async extractFacePaintImage(): Promise
{
+ try {
+ if (typeof window !== "undefined") {
+ throw new Error("sharp cannot run in the browser");
+ }
+
+ const { default: sharp } = await import("sharp");
+
+ const buf = Buffer.from(this.buffer);
+
+ const canvasMarker = Buffer.from([0xa3, 0xa3, 0xa3, 0xa3]);
+ const ugcMarker = Buffer.from([0xa4, 0xa4, 0xa4, 0xa4]);
+
+ const canvasStart = buf.indexOf(canvasMarker);
+ if (canvasStart === -1) return null;
+
+ const ugcStart = buf.indexOf(ugcMarker);
+ const canvasData = buf.subarray(canvasStart + 4, ugcStart === -1 ? undefined : ugcStart);
+
+ const decompressed = Buffer.from(fzstd.decompress(canvasData));
+ const deswizzled = new BytesDeswizzle(decompressed, [256, 256], [1, 1], 4, 4).deswizzle();
+
+ return await sharp(deswizzled, {
+ raw: { width: 256, height: 256, channels: 4 },
+ })
+ .png()
+ .toBuffer();
+ } catch (err) {
+ console.error("extractFacePaintImage failed:", err);
+ return null;
+ }
+ }
+
+ public toInstructions() {
+ const instructions: Partial = {
+ head: {
+ type: this.data.facelineType,
+ skinColor: this.data.facelineColor,
+ },
+ hair: {
+ set: this.data.hairType,
+ bangs: this.data.hairTypeFront,
+ back: this.data.hairTypeBack,
+ color: this.data.hairColor0,
+ subColor: this.data.hairColor1,
+ subColor2: this.data.hairColor0, // TODO: check
+ style: this.data.hairStyle,
+ isFlipped: (this.data.faceFlags & (1 << 2)) !== 0, // bangsSide
+ },
+ eyebrows: {
+ type: this.data.eyebrowType,
+ color: this.data.eyebrowColor,
+ height: this.data.eyebrowY - 10,
+ distance: this.data.eyebrowX - 4,
+ rotation: this.data.eyebrowRotate - 6,
+ size: this.data.eyebrowScale - 4,
+ stretch: this.data.eyebrowAspect - 3,
+ },
+ eyes: {
+ main: {
+ type: this.data.eyeType,
+ color: this.data.eyeColor,
+ height: this.data.eyeY - 12,
+ distance: this.data.eyeX - 2,
+ rotation: this.data.eyeRotate - 4,
+ size: this.data.eyeScale - 4,
+ stretch: this.data.eyeAspect - 3,
+ },
+ eyelashesTop: {
+ type: this.data.eyelashUpperType,
+ height: this.data.eyelashUpperY,
+ distance: this.data.eyelashUpperX,
+ rotation: this.data.eyelashUpperRotate,
+ size: this.data.eyelashUpperScale,
+ stretch: this.data.eyelashUpperAspect,
+ },
+ eyelashesBottom: {
+ type: this.data.eyelashLowerType,
+ height: this.data.eyelashLowerY,
+ distance: this.data.eyelashLowerX,
+ rotation: this.data.eyelashLowerRotate,
+ size: this.data.eyelashLowerScale,
+ stretch: this.data.eyelashLowerAspect,
+ },
+ eyelidTop: {
+ type: this.data.eyelidUpperType,
+ height: this.data.eyelidUpperY,
+ distance: this.data.eyelidUpperX,
+ rotation: this.data.eyelidUpperRotate,
+ size: this.data.eyelidUpperScale,
+ stretch: this.data.eyelidUpperAspect,
+ },
+ eyelidBottom: {
+ type: this.data.eyelidLowerType,
+ height: this.data.eyelidLowerY,
+ distance: this.data.eyelidLowerX,
+ rotation: this.data.eyelidLowerRotate,
+ size: this.data.eyelidLowerScale,
+ stretch: this.data.eyelidLowerAspect,
+ },
+ eyeliner: {
+ type: (this.data.faceFlags & (1 << 4)) !== 0, // eyeShadowEnabled
+ color: this.data.eyeShadowColor,
+ },
+ pupil: {
+ type: this.data.eyeHighlightType,
+ height: this.data.eyeHighlightY,
+ distance: this.data.eyeHighlightX,
+ rotation: this.data.eyeHighlightRotate,
+ size: this.data.eyeHighlightScale,
+ stretch: this.data.eyeHighlightAspect,
+ },
+ },
+ nose: {
+ type: this.data.noseType,
+ height: this.data.noseY - 9,
+ size: this.data.noseScale - 4,
+ },
+ lips: {
+ type: this.data.mouthType,
+ color: this.data.mouthColor,
+ height: this.data.mouthY - 13,
+ rotation: this.data.mouthRotate,
+ size: this.data.mouthScale - 4,
+ stretch: this.data.mouthAspect - 3,
+ hasLipstick: (this.data.faceFlags & (1 << 5)) !== 0, // mouthInvert
+ },
+ ears: {
+ type: this.data.earType,
+ height: this.data.earY - 4,
+ size: this.data.earScale - 2,
+ },
+ glasses: {
+ type: this.data.glassType1,
+ type2: this.data.glassType2,
+ ringColor: this.data.glassColor1,
+ shadesColor: this.data.glassColor2,
+ height: this.data.glassY - 11,
+ size: this.data.glassScale - 4,
+ stretch: this.data.glassAspect - 3,
+ },
+ other: {
+ wrinkles1: {
+ type: this.data.wrinkleLowerType,
+ height: this.data.wrinkleLowerY - 15,
+ distance: this.data.wrinkleLowerX - 2,
+ size: this.data.wrinkleLowerScale - 6,
+ stretch: this.data.wrinkleLowerAspect - 3,
+ },
+ wrinkles2: {
+ type: this.data.wrinkleUpperType,
+ height: this.data.wrinkleUpperY - 23,
+ distance: this.data.wrinkleUpperX - 7,
+ size: this.data.wrinkleUpperScale - 6,
+ stretch: this.data.wrinkleUpperAspect - 3,
+ },
+ beard: {
+ type: this.data.beardType,
+ color: this.data.beardColor,
+ },
+ moustache: {
+ type: this.data.mustacheType,
+ color: this.data.mustacheColor,
+ height: this.data.mustacheY - 10,
+ isFlipped: (this.data.faceFlags & (1 << 6)) !== 0, // mustacheInverted
+ size: this.data.mustacheScale - 4,
+ stretch: this.data.mustacheAspect - 3,
+ },
+ goatee: {
+ type: this.data.beardShortType,
+ color: this.data.beardShortColor,
+ },
+ mole: {
+ type: this.data.moleX != 0,
+ height: this.data.moleY - 20,
+ distance: this.data.moleX - 2,
+ size: this.data.moleScale - 4,
+ },
+ eyeShadow: {
+ type: this.data.makeup0,
+ color: this.data.makeup0Color,
+ height: this.data.makeup0Y - 12,
+ distance: this.data.makeup0X - 1,
+ size: this.data.makeup0Scale - 6,
+ stretch: this.data.makeup0Aspect - 3,
+ },
+ blush: {
+ type: this.data.makeup1,
+ color: this.data.makeup1Color,
+ height: this.data.makeup1Y - 19,
+ distance: this.data.makeup1X - 6,
+ size: this.data.makeup1Scale - 5,
+ stretch: this.data.makeup1Aspect - 3,
+ },
+ },
+ height: this.data.height,
+ weight: this.data.build,
+ datingPreferences: this.datingPreferences,
+ birthday: this.birthday,
+ voice: this.voice,
+ personality: this.personality,
+ };
+
+ return minifyInstructions(instructions);
+ }
+}
diff --git a/shared/src/switch.ts b/shared/src/switch.ts
index 98403f7..ee2d4f0 100644
--- a/shared/src/switch.ts
+++ b/shared/src/switch.ts
@@ -28,37 +28,40 @@ export function minifyInstructions(instructions: Partial)
}
export const defaultInstructions: SwitchMiiInstructions = {
- head: { skinColor: null },
+ head: { type: null, skinColor: null },
hair: {
+ set: null,
+ bangs: null,
+ back: null,
color: null,
subColor: null,
subColor2: null,
style: null,
isFlipped: false,
},
- eyebrows: { color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyebrows: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyes: {
- main: { color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
- eyelashesTop: { height: null, distance: null, rotation: null, size: null, stretch: null },
- eyelashesBottom: { height: null, distance: null, rotation: null, size: null, stretch: null },
- eyelidTop: { height: null, distance: null, rotation: null, size: null, stretch: null },
- eyelidBottom: { height: null, distance: null, rotation: null, size: null, stretch: null },
- eyeliner: { color: null },
- pupil: { height: null, distance: null, rotation: null, size: null, stretch: null },
+ main: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyelashesTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyelashesBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyelidTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyelidBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
+ eyeliner: { type: false, color: null },
+ pupil: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
},
- nose: { height: null, size: null },
- lips: { color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
- ears: { height: null, size: null },
- glasses: { ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
+ nose: { type: null, height: null, size: null },
+ lips: { type: null, color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
+ ears: { type: null, height: null, size: null },
+ glasses: { type: null, type2: null, ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
other: {
- wrinkles1: { height: null, distance: null, size: null, stretch: null },
- wrinkles2: { height: null, distance: null, size: null, stretch: null },
- beard: { color: null },
- moustache: { color: null, height: null, isFlipped: false, size: null, stretch: null },
- goatee: { color: null },
- mole: { color: null, height: null, distance: null, size: null },
- eyeShadow: { color: null, height: null, distance: null, size: null, stretch: null },
- blush: { color: null, height: null, distance: null, size: null, stretch: null },
+ wrinkles1: { type: null, height: null, distance: null, size: null, stretch: null },
+ wrinkles2: { type: null, height: null, distance: null, size: null, stretch: null },
+ beard: { type: null, color: null },
+ moustache: { type: null, color: null, height: null, isFlipped: false, size: null, stretch: null },
+ goatee: { type: null, color: null },
+ mole: { type: false, height: null, distance: null, size: null },
+ eyeShadow: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
+ blush: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
},
height: null,
weight: null,
diff --git a/shared/src/types.d.ts b/shared/src/types.d.ts
index d3a3e72..c31c936 100644
--- a/shared/src/types.d.ts
+++ b/shared/src/types.d.ts
@@ -5,9 +5,15 @@ type ReportReason = "INAPPROPRIATE" | "SPAM" | "BAD_QUALITY" | "OTHER";
export interface SwitchMiiInstructions {
head: {
+ type: number | null;
+
skinColor: number | null; // Additional 14 are not in color menu, default is 2
};
hair: {
+ set: number | null;
+ bangs: number | null;
+ back: number | null;
+
color: number | null;
subColor: number | null; // Default is none
subColor2: number | null; // Only used when bangs/back is selected
@@ -15,6 +21,8 @@ export interface SwitchMiiInstructions {
isFlipped: boolean; // Only for sets and fringe
};
eyebrows: {
+ type: number | null;
+
color: number | null;
height: number | null;
distance: number | null;
@@ -24,6 +32,8 @@ export interface SwitchMiiInstructions {
};
eyes: {
main: {
+ type: number | null;
+
color: number | null;
height: number | null;
distance: number | null;
@@ -32,6 +42,8 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
eyelashesTop: {
+ type: number | null;
+
height: number | null;
distance: number | null;
rotation: number | null;
@@ -39,6 +51,8 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
eyelashesBottom: {
+ type: number | null;
+
height: number | null;
distance: number | null;
rotation: number | null;
@@ -46,6 +60,8 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
eyelidTop: {
+ type: number | null;
+
height: number | null;
distance: number | null;
rotation: number | null;
@@ -53,6 +69,8 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
eyelidBottom: {
+ type: number | null;
+
height: number | null;
distance: number | null;
rotation: number | null;
@@ -60,9 +78,12 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
eyeliner: {
+ type: boolean;
color: number | null;
};
pupil: {
+ type: number | null;
+
height: number | null;
distance: number | null;
rotation: number | null;
@@ -71,10 +92,14 @@ export interface SwitchMiiInstructions {
};
};
nose: {
+ type: number | null;
+
height: number | null;
size: number | null;
};
lips: {
+ type: number | null;
+
color: number | null;
height: number | null;
rotation: number | null;
@@ -83,10 +108,15 @@ export interface SwitchMiiInstructions {
hasLipstick: boolean;
};
ears: {
+ type: number | null;
+
height: number | null; // Does not work for default
size: number | null; // Does not work for default
};
glasses: {
+ type: number | null;
+ type2: number | null;
+
ringColor: number | null;
shadesColor: number | null; // Only works after gap
height: number | null;
@@ -96,37 +126,50 @@ export interface SwitchMiiInstructions {
other: {
// names were assumed
wrinkles1: {
+ type: number | null;
+
height: number | null;
distance: number | null;
size: number | null;
stretch: number | null;
};
wrinkles2: {
+ type: number | null;
+
height: number | null;
distance: number | null;
size: number | null;
stretch: number | null;
};
beard: {
+ type: number | null;
+
color: number | null;
};
moustache: {
- color: number | null; // is this same as hair?
+ type: number | null;
+
+ color: number | null;
height: number | null;
isFlipped: boolean;
size: number | null;
stretch: number | null;
};
goatee: {
+ type: number | null;
+
color: number | null;
};
mole: {
- color: number | null; // is this same as hair?
+ type: boolean;
+
height: number | null;
distance: number | null;
size: number | null;
};
eyeShadow: {
+ type: number | null;
+
color: number | null;
height: number | null;
distance: number | null;
@@ -134,6 +177,8 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
blush: {
+ type: number | null;
+
color: number | null;
height: number | null;
distance: number | null;
@@ -141,7 +186,6 @@ export interface SwitchMiiInstructions {
stretch: number | null;
};
};
- // makeup, use video?
height: number | null;
weight: number | null;
datingPreferences: MiiGender[];