Compare commits

..

2 commits

Author SHA1 Message Date
27b0cc18a0 fix: temp - need to update .ltd parsing 2026-04-26 22:53:05 +01:00
817bda4993 feat: .ltd files
no automatic instructions
2026-04-26 22:49:29 +01:00
36 changed files with 1733 additions and 1037 deletions

View file

@ -14,7 +14,9 @@
"@2toad/profanity": "^3.3.0", "@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1", "@auth/prisma-adapter": "2.11.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@tomodachi-share/shared": "workspace:*",
"bit-buffer": "^0.3.0", "bit-buffer": "^0.3.0",
"charinfo-ex": "^0.0.5",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"file-type": "^22.0.1", "file-type": "^22.0.1",
@ -27,8 +29,7 @@
"satori": "^0.26.0", "satori": "^0.26.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"zod": "^4.3.6", "zod": "^4.3.6"
"@tomodachi-share/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false;

View file

@ -79,10 +79,11 @@ model Mii {
in_queue Boolean @default(false) in_queue Boolean @default(false)
needsFixing String? needsFixing String?
instructions Json? isFromSaveFile Boolean @default(false)
youtubeId String? instructions Json?
gender MiiGender? youtubeId String?
makeup MiiMakeup? gender MiiGender?
makeup MiiMakeup?
firstName String? firstName String?
lastName String? lastName String?

View file

@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
makeup, makeup,
allowCopying, allowCopying,
quarantined, quarantined,
isFromSaveFile,
page = 1, page = 1,
limit = 24, limit = 24,
parentPage, parentPage,
@ -59,17 +60,13 @@ export async function GET(request: NextRequest) {
// Tag filtering // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform // Other
...(platform && { platform: { equals: platform } }), ...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }), ...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }), ...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }), ...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }), ...(!quarantined && !userId && { quarantined: false }),
// Time range ...(isFromSaveFile && { isFromSaveFile: true }),
...(timeRange && { ...(timeRange && {
reviewedAt: { reviewedAt: {
gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]), gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]),

View file

@ -15,60 +15,56 @@ import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images"; import { generateMetadataImage, validateImage } from "@/lib/images";
import Mii from "../../../../../shared/src/mii.js/mii"; 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 { SwitchMiiInstructions } from "@tomodachi-share/shared";
import { settings } from "../../../lib/settings"; import { settings } from "../../../lib/settings";
import { CharInfoEx } from "charinfo-ex";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z const submitSchema = z.object({
.object({ platform: z.enum(MiiPlatform).default("THREE_DS"),
platform: z.enum(MiiPlatform).default("THREE_DS"), name: nameSchema,
name: nameSchema, tags: tagsSchema,
tags: tagsSchema, description: z.string().trim().max(512).optional(),
description: z.string().trim().max(512).optional(),
// Switch // Switch
gender: z.enum(MiiGender).default("MALE"), gender: z.enum(MiiGender).default("MALE"),
makeup: z.enum(MiiMakeup).default("PARTIAL"), makeup: z.enum(MiiMakeup).default("PARTIAL"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), youtubeId: z
youtubeId: z .string()
.string() .trim()
.trim() .transform((val) => (val === "" ? null : val))
.transform((val) => (val === "" ? null : val)) .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
.refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID") .optional(),
.optional(),
instructions: switchMiiInstructionsSchema,
// QR code way: z.enum(["savedata", "manual"]).optional(),
qrBytesRaw: z
.array(z.number(), { error: "A QR code is required" })
.length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
})
.nullish(),
// Custom images // Save data way
image1: z.union([z.instanceof(File), z.any()]).optional(), miiDataFile: z
image2: z.union([z.instanceof(File), z.any()]).optional(), .instanceof(File)
image3: z.union([z.instanceof(File), z.any()]).optional(), .refine((blob) => blob.size < 1024 * 1024 * 0.1, "File too large") // TODO: actual size
}) .optional(),
// This refine function is probably useless
.refine( // Manual way
(data) => { miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present instructions: switchMiiInstructionsSchema,
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined; // QR code
} qrBytesRaw: z
return true; .array(z.number(), { error: "A QR code is required" })
}, .length(372, {
{ error: "QR code size is not a valid Tomodachi Life QR code",
message: "Gender, Mii portrait & features image are required for Switch platform", })
path: ["gender", "miiPortraitImage", "miiFeaturesImage"], .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) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
@ -106,8 +102,12 @@ export async function POST(request: NextRequest) {
gender: formData.get("gender") ?? undefined, // ZOD MOMENT gender: formData.get("gender") ?? undefined, // ZOD MOMENT
makeup: formData.get("makeup") ?? undefined, makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"), miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"), youtubeId: formData.get("youtubeId"),
way: formData.get("way"),
miiDataFile: formData.get("miiDataFile") ?? undefined,
miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions, instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw, qrBytesRaw: rawQrBytesRaw,
@ -131,6 +131,8 @@ export async function POST(request: NextRequest) {
qrBytesRaw, qrBytesRaw,
gender, gender,
makeup, makeup,
way,
miiDataFile,
miiPortraitImage, miiPortraitImage,
miiFeaturesImage, miiFeaturesImage,
youtubeId, youtubeId,
@ -161,9 +163,10 @@ export async function POST(request: NextRequest) {
// Check Mii portrait & features image (Switch) // Check Mii portrait & features image (Switch)
if (platform === "SWITCH") { if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage); const portraitValidation = await validateImage(miiPortraitImage);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid) if (!portraitValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400); return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!featuresValidation.valid) if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400); 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 // Create Mii in database
const miiRecord = await prisma.mii.create({ const miiRecord = await prisma.mii.create({
data: { data: {
@ -204,6 +222,7 @@ export async function POST(request: NextRequest) {
youtubeId, youtubeId,
instructions: minifiedInstructions, instructions: minifiedInstructions,
makeup: makeup ?? "PARTIAL", makeup: makeup ?? "PARTIAL",
...(way === "savedata" && { isFromSaveFile: true }),
}), }),
}, },
}); });
@ -228,18 +247,20 @@ export async function POST(request: NextRequest) {
} else if (platform === "SWITCH") { } else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer()); portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
// Save features image
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer()); const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
const pngBuffer = await sharp(featuresBuffer) const pngBuffer = await sharp(featuresBuffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
.resize({ await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
height: 800,
fit: "inside", if (way === "savedata" && miiDataFileBuffer) {
withoutEnlargement: true, await fs.writeFile(path.join(miiUploadsDirectory, "data.ltd"), Buffer.from(miiDataFileBuffer));
})
.png({ quality: 85 }) if (parsedSwitchMii) {
.toBuffer(); const pngBuffer = await parsedSwitchMii.extractFacePaintImage();
const fileLocation = path.join(miiUploadsDirectory, "features.png"); if (pngBuffer) await fs.writeFile(path.join(miiUploadsDirectory, "facepaint.png"), pngBuffer);
await fs.writeFile(fileLocation, pngBuffer); } else {
return rateLimit.sendResponse({ error: "Failed to extract Switch Mii data" }, 500);
}
}
} }
// Save portrait image // Save portrait image

View file

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

View file

@ -12,8 +12,8 @@ import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
type: z type: z
.enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], { .enum(["mii", "qr-code", "features", "facepaint", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'", message: "Image type must be either 'mii', 'qr-code', 'features', 'facepaint', 'image[number from 0 to 2]' or 'metadata'",
}) })
.default("mii"), .default("mii"),
}); });

View file

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

View file

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View file

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View file

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -1,49 +1,48 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone"; import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface Props { interface Props {
onDrop: (acceptedFiles: FileWithPath[]) => void; type?: "file" | "image";
options?: DropzoneOptions; onDrop: (acceptedFiles: FileWithPath[]) => void;
children?: ReactNode; options?: DropzoneOptions;
} children?: ReactNode;
}
export default function Dropzone({ onDrop, options, children }: Props) {
const [isDraggingOver, setIsDraggingOver] = useState(false); export default function Dropzone({ type = "image", onDrop, options, children }: Props) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const handleDrop = (acceptedFiles: FileWithPath[]) => {
setIsDraggingOver(false); const handleDrop = (acceptedFiles: FileWithPath[]) => {
onDrop(acceptedFiles); setIsDraggingOver(false);
}; onDrop(acceptedFiles);
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop, const { getRootProps, getInputProps } = useDropzone({
maxFiles: 3, onDrop: handleDrop,
accept: { maxFiles: 3,
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"], accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] },
}, ...options,
...options, });
});
return (
return ( <div
<div {...getRootProps()}
{...getRootProps()} onDragOver={() => setIsDraggingOver(true)}
onDragOver={() => setIsDraggingOver(true)} onDragLeave={() => setIsDraggingOver(false)}
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 ${
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"
isDraggingOver && "scale-105 brightness-90 shadow-xl" }`}
}`} >
> {/* Used to transition from border-dashed to border-solid */}
{/* Used to transition from border-dashed to border-solid */} <div
<div className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${ isDraggingOver ? "opacity-100" : "opacity-0"
isDraggingOver ? "opacity-100" : "opacity-0" }`}
}`} ></div>
></div>
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} /> <Icon icon="material-symbols:upload" fontSize={48} />
<Icon icon="material-symbols:upload" fontSize={48} /> {children}
{children} </div>
</div> );
); }
}

View file

@ -1,165 +1,165 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface Props { interface Props {
src: string; src: string;
alt: string; alt: string;
width: number; width: number;
height: number; height: number;
className?: string; className?: string;
images?: string[]; images?: string[];
} }
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) { export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 }); const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]); const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const close = () => { const close = () => {
setIsVisible(false); setIsVisible(false);
setTimeout(() => { setTimeout(() => {
setIsOpen(false); setIsOpen(false);
}, 300); }, 300);
}; };
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// slight delay to trigger animation // slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10); setTimeout(() => setIsVisible(true), 10);
} }
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
if (!emblaApi) return; if (!emblaApi) return;
// Keep order of images whilst opening at src prop // Keep order of images whilst opening at src prop
const index = images.indexOf(src); const index = images.indexOf(src);
if (index !== -1) { if (index !== -1) {
emblaApi.scrollTo(index, true); emblaApi.scrollTo(index, true);
setSelectedIndex(index); setSelectedIndex(index);
} }
setScrollSnaps(emblaApi.scrollSnapList()); setScrollSnaps(emblaApi.scrollSnapList());
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi, images, src]); }, [emblaApi, images, src]);
// Handle keyboard events // Handle keyboard events
useEffect(() => { useEffect(() => {
if (!isOpen || !emblaApi) return; if (!isOpen || !emblaApi) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") emblaApi.scrollPrev(); if (event.key === "ArrowLeft") emblaApi.scrollPrev();
else if (event.key === "ArrowRight") emblaApi.scrollNext(); else if (event.key === "ArrowRight") emblaApi.scrollNext();
else if (event.key === "Escape") close(); else if (event.key === "Escape") close();
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [isOpen, emblaApi]); }, [isOpen, emblaApi]);
const imagesMap = images.length === 0 ? [src] : images; const imagesMap = images.length === 0 ? [src] : images;
return ( return (
<> <>
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */} {/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} /> <img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40"> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`} className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/> />
<button <button
type="button" type="button"
aria-label="Close" aria-label="Close"
onClick={close} onClick={close}
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`} className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
> >
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
<div <div
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`} className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
ref={emblaRef} ref={emblaRef}
> >
<div className="flex h-full"> <div className="flex h-full">
{imagesMap.map((image, index) => ( {imagesMap.map((image, index) => (
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4"> <div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
<img <img
src={image} src={image}
alt={alt} alt={alt}
width={896} width={896}
height={896} height={896}
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"} loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
className="max-w-full max-h-full object-contain drop-shadow-lg" className="max-w-full max-h-full object-contain drop-shadow-lg"
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }} style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
/> />
</div> </div>
))} ))}
</div> </div>
</div> </div>
{images.length > 1 && ( {images.length > 1 && (
<> <>
{/* Carousel counter */} {/* Carousel counter */}
<div <div
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${ className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0" isVisible ? "opacity-100" : "opacity-0"
}`} }`}
> >
{selectedIndex + 1} / {images.length} {selectedIndex + 1} / {images.length}
</div> </div>
{/* Carousel buttons */} {/* Carousel buttons */}
{/* Prev button */} {/* Prev button */}
<button <button
type="button" type="button"
aria-label="Scroll Carousel Left" aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()} onClick={() => 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"}`} 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"}`}
> >
<Icon icon="ic:round-chevron-left" /> <Icon icon="ic:round-chevron-left" />
</button> </button>
{/* Next button */} {/* Next button */}
<button <button
type="button" type="button"
aria-label="Scroll Carousel Right" aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()} onClick={() => 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"}`} 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"}`}
> >
<Icon icon="ic:round-chevron-right" /> <Icon icon="ic:round-chevron-right" />
</button> </button>
{/* Carousel snaps */} {/* Carousel snaps */}
<div <div
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${ className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0" isVisible ? "opacity-100" : "opacity-0"
}`} }`}
> >
{scrollSnaps.map((_, index) => ( {scrollSnaps.map((_, index) => (
<button <button
key={index} key={index}
aria-label={`Go to ${index} in Carousel`} aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)} onClick={() => 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"}`} className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
/> />
))} ))}
</div> </div>
</> </>
)} )}
</div>, </div>,
document.body, document.body,
)} )}
</> </>
); );
} }

View file

@ -1,254 +1,254 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import DatingPreferencesViewer from "./dating-preferences"; import DatingPreferencesViewer from "./dating-preferences";
import PersonalityViewer from "./personality-viewer"; import PersonalityViewer from "./personality-viewer";
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared"; import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
interface Props { interface Props {
instructions: Partial<SwitchMiiInstructions>; instructions: Partial<SwitchMiiInstructions>;
} }
interface SectionProps { interface SectionProps {
name: string; name: string;
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>; instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
children?: ReactNode; children?: ReactNode;
isSubSection?: boolean; isSubSection?: boolean;
} }
const ORDINAL_SUFFIXES: Record<string, string> = { const ORDINAL_SUFFIXES: Record<string, string> = {
one: "st", one: "st",
two: "nd", two: "nd",
few: "rd", few: "rd",
other: "th", other: "th",
}; };
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
function not(value: any) { function not(value: any) {
return value !== undefined && value !== null; return value !== undefined && value !== null;
} }
function numberValue(value: number, cutoff: number = 25) { function numberValue(value: number, cutoff: number = 25) {
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`; return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
} }
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) { function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
const row = Math.floor(index / cols) + 1; const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1; const col = (index % cols) + 1;
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)]; const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)]; const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
return `${row}${rowSuffix} row, ${col}${colSuffix} column`; return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
} }
function ColorPosition({ color }: { color: number | undefined | null }) { function ColorPosition({ color }: { color: number | undefined | null }) {
if (color === undefined || color === null) return null; if (color === undefined || color === null) return null;
if (color <= 7) { if (color <= 7) {
return ( return (
<span className="flex items-center"> <span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div> <div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on left, <GridPosition index={color} cols={1} /> Color menu on left, <GridPosition index={color} cols={1} />
</span> </span>
); );
} }
if (color >= 108) { if (color >= 108) {
return ( return (
<span className="flex items-center"> <span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div> <div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Outside color menu, <GridPosition index={color - 108} cols={2} /> Outside color menu, <GridPosition index={color - 108} cols={2} />
</span> </span>
); );
} }
return ( return (
<span className="flex items-center"> <span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div> <div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} /> Color menu on right, <GridPosition index={color - 8} cols={10} />
</span> </span>
); );
} }
interface TableCellProps { interface TableCellProps {
label: string; label: string;
children: React.ReactNode; children: React.ReactNode;
} }
function TableCell({ label, children }: TableCellProps) { function TableCell({ label, children }: TableCellProps) {
return ( return (
<tr className={"border-b border-orange-300/50 last:border-0"}> <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 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
<td className={"py-0.5 text-amber-950"}>{children}</td> <td className={"py-0.5 text-amber-950"}>{children}</td>
</tr> </tr>
); );
} }
function Section({ name, instructions, children, isSubSection }: SectionProps) { function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object" || !instructions) return null; if (typeof instructions !== "object" || !instructions) return null;
const color = "color" in instructions ? instructions.color : undefined; const color = "color" in instructions ? instructions.color : undefined;
const height = "height" in instructions ? instructions.height : undefined; const height = "height" in instructions ? instructions.height : undefined;
const distance = "distance" in instructions ? instructions.distance : undefined; const distance = "distance" in instructions ? instructions.distance : undefined;
const rotation = "rotation" in instructions ? instructions.rotation : undefined; const rotation = "rotation" in instructions ? instructions.rotation : undefined;
const size = "size" in instructions ? instructions.size : undefined; const size = "size" in instructions ? instructions.size : undefined;
const stretch = "stretch" in instructions ? instructions.stretch : undefined; const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return ( return (
<div className={`p-3 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}> <div className={`p-3 w-max not-last:mb-3 ${isSubSection ? "not-first:mt-2 pt-0!" : "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> <h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
{not(color) && ( {not(color) && (
<TableCell label="Color"> <TableCell label="Color">
<ColorPosition color={color} /> <ColorPosition color={color} />
</TableCell> </TableCell>
)} )}
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>} {not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>} {not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>} {not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>} {not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>} {not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>}
{children} {children}
</tbody> </tbody>
</table> </table>
</div> </div>
); );
} }
export default function MiiInstructions({ instructions }: Props) { export default function MiiInstructions({ instructions }: Props) {
if (Object.keys(instructions).length === 0) return null; 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; const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
return ( return (
<> <>
{head && ( {head && (
<Section name="Head" instructions={head}> <Section name="Head" instructions={head}>
{not(head.skinColor) && ( {not(head.skinColor) && (
<TableCell label="Skin Color"> <TableCell label="Skin Color">
<ColorPosition color={head.skinColor} /> <ColorPosition color={head.skinColor} />
</TableCell> </TableCell>
)} )}
</Section> </Section>
)} )}
{hair && ( {hair && (
<Section name="Hair" instructions={hair}> <Section name="Hair" instructions={hair}>
{not(hair.subColor) && ( {not(hair.subColor) && (
<TableCell label="Sub Color"> <TableCell label="Sub Color">
<ColorPosition color={hair.subColor} /> <ColorPosition color={hair.subColor} />
</TableCell> </TableCell>
)} )}
{not(hair.subColor2) && ( {not(hair.subColor2) && (
<TableCell label="Sub Color (Back)"> <TableCell label="Sub Color (Back)">
<ColorPosition color={hair.subColor2} /> <ColorPosition color={hair.subColor2} />
</TableCell> </TableCell>
)} )}
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>} {not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>} {not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
</Section> </Section>
)} )}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>} {eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && ( {eyes && (
<Section name="Eyes" instructions={eyes}> <Section name="Eyes" instructions={eyes}>
<Section isSubSection name="Tab 1" instructions={eyes.main} /> <Section isSubSection name="Tab 1" instructions={eyes.main} />
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} /> <Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} /> <Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} /> <Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} /> <Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} /> <Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
<Section isSubSection name="Tab 7" instructions={eyes.pupil} /> <Section isSubSection name="Tab 7" instructions={eyes.pupil} />
</Section> </Section>
)} )}
{nose && <Section name="Nose" instructions={nose}></Section>} {nose && <Section name="Nose" instructions={nose}></Section>}
{lips && ( {lips && (
<Section name="Lips" instructions={lips}> <Section name="Lips" instructions={lips}>
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>} {not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
</Section> </Section>
)} )}
{ears && <Section name="Ears" instructions={ears}></Section>} {ears && <Section name="Ears" instructions={ears}></Section>}
{glasses && ( {glasses && (
<Section name="Glasses" instructions={glasses}> <Section name="Glasses" instructions={glasses}>
{not(glasses.ringColor) && ( {not(glasses.ringColor) && (
<TableCell label="Ring Color"> <TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} /> <ColorPosition color={glasses.ringColor} />
</TableCell> </TableCell>
)} )}
{not(glasses.shadesColor) && ( {not(glasses.shadesColor) && (
<TableCell label="Shades Color"> <TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} /> <ColorPosition color={glasses.shadesColor} />
</TableCell> </TableCell>
)} )}
</Section> </Section>
)} )}
{other && ( {other && (
<Section name="Other" instructions={other}> <Section name="Other" instructions={other}>
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} /> <Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} /> <Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
<Section isSubSection name="Tab 3" instructions={other.beard} /> <Section isSubSection name="Tab 3" instructions={other.beard} />
<Section isSubSection name="Tab 4" instructions={other.moustache}> <Section isSubSection name="Tab 4" instructions={other.moustache}>
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>} {other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section> </Section>
<Section isSubSection name="Tab 5" instructions={other.goatee} /> <Section isSubSection name="Tab 5" instructions={other.goatee} />
<Section isSubSection name="Tab 6" instructions={other.mole} /> <Section isSubSection name="Tab 6" instructions={other.mole as any} />
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} /> <Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
<Section isSubSection name="Tab 8" instructions={other.blush} /> <Section isSubSection name="Tab 8" instructions={other.blush} />
</Section> </Section>
)} )}
{(height || weight || datingPreferences || voice || personality) && ( {(height || weight || datingPreferences || voice || personality) && (
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max"> <div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3> <h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>} {not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>} {not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
</tbody> </tbody>
</table> </table>
{birthday && ( {birthday && (
<div className="pl-2 not-nth-2:mt-4"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>} {not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>} {not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>} {not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>} {not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{voice && ( {voice && (
<div className="pl-2 not-nth-2:mt-4"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>} {not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>} {not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>} {not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>} {not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>} {not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{datingPreferences && ( {datingPreferences && (
<div className="pl-2 not-nth-2:mt-4"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
<div className="w-min"> <div className="w-min">
<DatingPreferencesViewer data={datingPreferences} /> <DatingPreferencesViewer data={datingPreferences} />
</div> </div>
</div> </div>
)} )}
{personality && ( {personality && (
<div className="pl-2 not-nth-2:mt-4"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
<div className="w-min"> <div className="w-min">
<PersonalityViewer data={personality} /> <PersonalityViewer data={personality} />
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</> </>
); );
} }

View file

@ -9,9 +9,27 @@ export default function OtherFilters() {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined; const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const [hasShareMiiFile, setHasShareMiiFile] = useState<boolean>((searchParams.get("sharemii") as unknown as boolean) ?? false);
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false); const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false); const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
const handleChangeHasShareMiiFile = (e: ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => { const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
setAllowCopying(e.target.checked); setAllowCopying(e.target.checked);
@ -59,6 +77,14 @@ export default function OtherFilters() {
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{platform !== "THREE_DS" && (
<div className="flex justify-between items-center w-full mb-1">
<label htmlFor="ltdfile" className="text-sm">
Has ShareMii File
</label>
<input type="checkbox" id="ltdfile" className="checkbox-alt" checked={hasShareMiiFile} onChange={handleChangeHasShareMiiFile} />
</div>
)}
{showAllowCopying && ( {showAllowCopying && (
<div className="flex justify-between items-center w-full mb-1"> <div className="flex justify-between items-center w-full mb-1">
<label htmlFor="allowCopying" className="text-sm"> <label htmlFor="allowCopying" className="text-sm">

View file

@ -1,73 +1,83 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { type FileWithPath } from "react-dropzone"; import { type FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
import Camera from "./camera"; import Camera from "./camera";
import ImageEditorPortrait from "./image-editor"; import ImageEditorPortrait from "./image-editor";
interface Props { interface Props {
text: string; text: string;
forceCrop?: boolean; type?: "file" | "image";
image?: string | undefined; forceCrop?: boolean;
setImage: (value: string | undefined) => void; file?: string | File | undefined;
} setFile?: (value: File | undefined) => void;
image?: string | undefined;
export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) { setImage?: (value: string | undefined) => void;
const [isCameraOpen, setIsCameraOpen] = useState(false); }
const [isCropOpen, setIsCropOpen] = useState(false);
export default function SwitchFileUpload({ text, type = "image", forceCrop, file, setFile, image, setImage }: Props) {
const handleDrop = useCallback( const [isCameraOpen, setIsCameraOpen] = useState(false);
(acceptedFiles: FileWithPath[]) => { const [isCropOpen, setIsCropOpen] = useState(false);
const file = acceptedFiles[0];
// Convert to Data URI const handleDrop = useCallback(
const reader = new FileReader(); (acceptedFiles: FileWithPath[]) => {
reader.onload = async (event) => { const file = acceptedFiles[0];
setImage(event.target!.result as string); if (type === "file") {
if (forceCrop) setIsCropOpen(true); setFile!(file);
}; } else {
reader.readAsDataURL(file); const reader = new FileReader();
}, reader.onload = (event) => {
[setImage], setImage!(event.target!.result as string);
); if (forceCrop) setIsCropOpen(true);
};
return ( reader.readAsDataURL(file);
<div className="max-w-md w-full flex flex-col items-center gap-2"> }
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}> },
<p className="text-center text-sm"> [setFile, setImage],
{!image ? ( );
<>
Drag and drop {text} return (
<br /> <div className="max-w-md w-full flex flex-col items-center gap-2">
or click to open <Dropzone type={type} onDrop={handleDrop} options={{ maxFiles: 1 }}>
</> <p className="text-center text-sm">
) : ( {!file && !image ? (
"Uploaded!" <>
)} Drag and drop {text}
</p> <br />
</Dropzone> or click to open
</>
<span>or</span> ) : (
"Uploaded!"
<div className="flex gap-2 max-sm:flex-col"> )}
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2"> </p>
<Icon icon="mdi:camera" fontSize={20} /> </Dropzone>
Use your camera
</button> {type === "image" && (
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2"> <>
<Icon icon="mdi:image-edit" fontSize={20} /> <span>or</span>
Edit Image
</button> <div className="flex gap-2 max-sm:flex-col">
</div> <button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
<Camera Use your camera
isOpen={isCameraOpen} </button>
setIsOpen={setIsCameraOpen} <button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
setImage={setImage} <Icon icon="mdi:image-edit" fontSize={20} />
onCapture={() => { Edit Image
if (forceCrop) setIsCropOpen(true); </button>
}} </div>
/>
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} /> <Camera
</div> isOpen={isCameraOpen}
); setIsOpen={setIsCameraOpen}
} setImage={setImage}
onCapture={() => {
if (forceCrop) setIsCropOpen(true);
}}
/>
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage!} />
</>
)}
</div>
);
}

View file

@ -1,205 +1,211 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
interface Slide { interface Slide {
// step is never used, undefined is assumed as a step type?: "start" | "step" | "finish"; // step is never specified, undefined is assumed as step
type?: "start" | "step" | "finish"; text?: string;
text?: string; link?: string;
imageSrc?: string; imageSrc?: string;
} }
interface Tutorial { interface Tutorial {
title: string; title: string;
thumbnail?: string; thumbnail?: string;
hint?: string; hint?: string;
steps: Slide[]; steps: Slide[];
} }
interface Props { interface Props {
tutorials: Tutorial[]; tutorials: Tutorial[];
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
} }
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) { export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 }); const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map // Build index map
const slides: Array<Slide & { tutorialTitle: string }> = []; const slides: Array<Slide & { tutorialTitle: string }> = [];
const startSlides: Record<string, number> = {}; const startSlides: Record<string, number> = {};
tutorials.forEach((tutorial) => { tutorials.forEach((tutorial) => {
tutorial.steps.forEach((slide) => { tutorial.steps.forEach((slide) => {
if (slide.type === "start") { if (slide.type === "start") {
startSlides[tutorial.title] = slides.length; startSlides[tutorial.title] = slides.length;
} }
slides.push({ ...slide, tutorialTitle: tutorial.title }); slides.push({ ...slide, tutorialTitle: tutorial.title });
}); });
}); });
const currentSlide = slides[selectedIndex]; const currentSlide = slides[selectedIndex];
const isStartingPage = currentSlide?.type === "start"; const isStartingPage = currentSlide?.type === "start";
useEffect(() => { useEffect(() => {
if (currentSlide.type !== "finish") return; if (currentSlide.type !== "finish") return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 }; const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min; const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => { setTimeout(() => {
confetti({ confetti({
...defaults, ...defaults,
particleCount: 500, particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
}); });
confetti({ confetti({
...defaults, ...defaults,
particleCount: 500, particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
}); });
}, 300); }, 300);
}, [currentSlide]); }, [currentSlide]);
const close = () => { const close = () => {
setIsVisible(false); setIsVisible(false);
setTimeout(() => { setTimeout(() => {
setIsOpen(false); setIsOpen(false);
setSelectedIndex(0); setSelectedIndex(0);
}, 300); }, 300);
}; };
const goToTutorial = (tutorialTitle: string) => { const goToTutorial = (tutorialTitle: string) => {
if (!emblaApi) return; if (!emblaApi) return;
const index = startSlides[tutorialTitle]; const index = startSlides[tutorialTitle];
// Jump to next starting slide then transition to actual tutorial // Jump to next starting slide then transition to actual tutorial
emblaApi.scrollTo(index, true); emblaApi.scrollTo(index, true);
emblaApi.scrollTo(index + 1); emblaApi.scrollTo(index + 1);
}; };
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// slight delay to trigger animation // slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10); setTimeout(() => setIsVisible(true), 10);
} }
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
if (!emblaApi) return; if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]); }, [emblaApi]);
return ( return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40"> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`} className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/> />
<div <div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-xl h-120 transition-discrete duration-300 flex flex-col ${ className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-xl h-120 transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`} }`}
> >
<div className="flex justify-between items-center mb-2 p-6 pb-0"> <div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2> <h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
</div> </div>
<div className="flex flex-col min-h-0 h-full"> <div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}> <div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full"> <div className="flex h-full">
{slides.map((slide, index) => ( {slides.map((slide, index) => (
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}> <div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
{slide.type === "start" ? ( {slide.type === "start" ? (
<> <>
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Pick a tutorial</span> <span>Pick a tutorial</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="grid grid-cols-2 gap-4 h-full"> <div className="grid grid-cols-2 gap-4 h-full">
{tutorials.map((tutorial, tutorialIndex) => ( {tutorials.map((tutorial, tutorialIndex) => (
<button <button
key={tutorialIndex} key={tutorialIndex}
onClick={() => goToTutorial(tutorial.title)} onClick={() => goToTutorial(tutorial.title)}
aria-label={tutorial.title + " tutorial"} 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" 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"
> >
<img <img
src={tutorial.thumbnail!} src={tutorial.thumbnail!}
alt="tutorial thumbnail" alt="tutorial thumbnail"
width={128} width={128}
height={128} height={128}
className="rounded-lg border-2 border-zinc-300 object-cover" className="rounded-lg border-2 border-zinc-300 object-cover"
/> />
<p className="mt-2">{tutorial.title}</p> <p className="mt-2">{tutorial.title}</p>
{/* Set opacity to 0 to keep height the same with other tutorials */} {/* Set opacity to 0 to keep height the same with other tutorials */}
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p> <p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
</button> </button>
))} ))}
</div> </div>
</> </>
) : slide.type === "finish" ? ( ) : slide.type === "finish" ? (
<div className="h-full flex flex-col justify-center items-center"> <div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" /> <Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1> <h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div> </div>
) : ( ) : (
<> <>
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p> {slide.link ? (
<a href={slide.link} className="text-sm text-blue-600 mb-2 text-center">
<img {slide.text}
src={slide.imageSrc ?? "/missing.svg"} </a>
alt="step image" ) : (
width={396} <p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
height={320} )}
loading="eager"
className="rounded-lg w-full h-full object-contain bg-black flex-1" <img
/> src={slide.imageSrc ?? "/missing.svg"}
</> alt="step image"
)} width={396}
</div> height={320}
))} loading="eager"
</div> className="rounded-lg w-full h-full object-contain bg-black flex-1"
</div> />
</>
{/* Arrows */} )}
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}> </div>
<button ))}
onClick={() => emblaApi?.scrollPrev()} </div>
disabled={isStartingPage} </div>
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Left" {/* Arrows */}
> <div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<Icon icon="tabler:chevron-left" /> <button
</button> onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
{/* Only show tutorial name on step slides */} className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
<span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}> aria-label="Scroll Carousel Left"
{currentSlide?.tutorialTitle} >
</span> <Icon icon="tabler:chevron-left" />
</button>
<button
onClick={() => emblaApi?.scrollNext()} {/* Only show tutorial name on step slides */}
disabled={isStartingPage} <span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`} {currentSlide?.tutorialTitle}
aria-label="Scroll Carousel Right" </span>
>
<Icon icon="tabler:chevron-right" /> <button
</button> onClick={() => emblaApi?.scrollNext()}
</div> disabled={isStartingPage}
</div> className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
</div> aria-label="Scroll Carousel Right"
</div> >
); <Icon icon="tabler:chevron-right" />
} </button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,54 +1,78 @@
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Tutorial from "."; import Tutorial from ".";
export default function SwitchAddMiiTutorialButton() { export default function SwitchAddMiiTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<> <>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer"> <button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" /> <Icon icon="fa:question-circle" />
<span>Tutorial</span> <span>Tutorial</span>
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<Tutorial <Tutorial
tutorials={[ tutorials={[
{ {
title: "Adding Mii", title: "ShareMii (Modded)",
steps: [ thumbnail: "/tutorial/switch/adding-mii/modded/thumbnail.png",
{ steps: [
text: "1. Press X to open the menu, then select 'Add a Mii'", { type: "start" },
imageSrc: "/tutorial/switch/adding-mii/step1.jpg", {
}, text: "1. Download ShareMii - click here for link",
{ link: "https://gamebanana.com/tools/22305",
text: "2. Press 'From scratch' and choose the Male template", imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
imageSrc: "/tutorial/switch/adding-mii/step2.jpg", },
}, {
{ text: "2. Download the .ltd file, it is above the instructions next to all the other buttons",
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor", imageSrc: "/tutorial/switch/adding-mii/modded/step2.png",
imageSrc: "/tutorial/switch/adding-mii/step3.png", },
}, {
{ text: "3. Follow the instructions by the creator (scroll down to importing) - click here for link",
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)", link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
imageSrc: "/tutorial/switch/adding-mii/step4.jpg", imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
}, },
{ { type: "finish" },
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)", ],
imageSrc: "/tutorial/switch/step4.jpg", },
}, {
{ type: "finish" }, title: "Manual",
], thumbnail: "/tutorial/switch/adding-mii/manual/thumbnail.png",
}, steps: [
]} { type: "start" },
isOpen={isOpen} {
setIsOpen={setIsOpen} text: "1. Press X to open the menu, then select 'Add a Mii'",
/>, imageSrc: "/tutorial/switch/adding-mii/manual/step1.jpg",
document.body, },
)} {
</> text: "2. Press 'From scratch' and choose the Male template",
); imageSrc: "/tutorial/switch/adding-mii/manual/step2.jpg",
} },
{
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor (This won't work if the Mii is from a save file! You can see the icons in the instructions)",
imageSrc: "/tutorial/switch/adding-mii/manual/step3.png",
},
{
text: "4. If the Mii has instructions, follow them (not all instructions will be there if not from save data, check next slide for more)",
imageSrc: "/tutorial/switch/adding-mii/manual/step4.jpg",
},
{
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -1,48 +1,67 @@
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Tutorial from "."; import Tutorial from ".";
export default function SwitchSubmitTutorialButton() { interface Props {
const [isOpen, setIsOpen] = useState(false); type: "savedata" | "manual";
}
return (
<> export default function SwitchSubmitTutorialButton({ type }: Props) {
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"> const [isOpen, setIsOpen] = useState(false);
How to?
</button> return (
<>
{isOpen && <button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
createPortal( How to?
<Tutorial </button>
tutorials={[
{ {isOpen &&
title: "Submitting", createPortal(
steps: [ <Tutorial
{ tutorials={[
text: "1. Press X to open the menu, then select 'Residents'", {
imageSrc: "/tutorial/switch/submitting/step1.jpg", title: "Submitting",
}, steps:
{ type === "savedata"
text: "2. Find the Mii you want to submit and edit it", ? [
imageSrc: "/tutorial/switch/submitting/step2.jpg", {
}, text: "1. Download ShareMii - click here for link",
{ link: "https://gamebanana.com/tools/22305",
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form", imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
imageSrc: "/tutorial/switch/submitting/step3.jpg", },
}, {
{ text: "2. Follow the instructions by the creator (scroll down to exporting) - click here for link",
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)", link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
imageSrc: "/tutorial/switch/step4.jpg", imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
}, },
{ type: "finish" }, { type: "finish" },
], ]
}, : [
]} {
isOpen={isOpen} text: "1. Press X to open the menu, then select 'Residents'",
setIsOpen={setIsOpen} imageSrc: "/tutorial/switch/submitting/step1.jpg",
/>, },
document.body, {
)} text: "2. Find the Mii you want to submit and edit it",
</> imageSrc: "/tutorial/switch/submitting/step2.jpg",
); },
} {
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
imageSrc: "/tutorial/switch/submitting/step3.jpg",
},
{
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -423,7 +423,7 @@ export default function EditMiiPage() {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop /> <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} /> <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
<SwitchSubmitTutorialButton /> <SwitchSubmitTutorialButton type="manual" />
</div> </div>
<p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p> <p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
@ -457,7 +457,7 @@ export default function EditMiiPage() {
</div> </div>
<MiiEditor instructions={instructions} /> <MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton /> <SwitchSubmitTutorialButton type="manual" />
</> </>
)} )}

View file

@ -135,13 +135,47 @@ export default function MiiPage() {
/> />
</div> </div>
) : ( ) : (
<ImageViewer <>
src={`${API_URL}/mii/${mii.id}/image?type=features`} <ImageViewer
alt="mii features" src={`${API_URL}/mii/${mii.id}/image?type=features`}
width={300} alt="mii features"
height={300} width={300}
className="rounded-lg hover:brightness-90 mb-4 transition-all" height={300}
/> className="rounded-lg hover:brightness-90 mb-4 transition-all"
/>
{mii.isFromSaveFile && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-4 w-full">
<hr className="grow border-zinc-300" />
<span>Face Paint Texture</span>
<hr className="grow border-zinc-300" />
</div>
<div
className="rounded-lg mb-4 overflow-hidden"
style={{
backgroundImage: `
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
}}
>
<ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=facepaint`}
alt="mii facepaint"
width={300}
height={300}
className="rounded-lg hover:brightness-90 transition-all"
/>
</div>
</>
)}
</>
)} )}
<hr className="w-full border-t-2 border-t-amber-400" /> <hr className="w-full border-t-2 border-t-amber-400" />
@ -340,9 +374,15 @@ export default function MiiPage() {
</div> </div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs"> <div className="flex gap-4 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
<AuthorButtons mii={mii} /> <AuthorButtons mii={mii} />
{mii.isFromSaveFile && (
<a aria-label="Download Mii" href={`${API_URL}/mii/${mii.id}/download`} download>
<Icon icon="material-symbols:download" />
<span>Download</span>
</a>
)}
<ShareMiiButton miiId={mii.id} /> <ShareMiiButton miiId={mii.id} />
<Link aria-label="Report Mii" to={`/report/mii/${mii.id}`}> <Link aria-label="Report Mii" to={`/report/mii/${mii.id}`}>
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
@ -353,11 +393,16 @@ export default function MiiPage() {
{/* Instructions */} {/* Instructions */}
{mii.platform === "SWITCH" && ( {mii.platform === "SWITCH" && (
<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"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2"> <h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<Icon icon="fa7-solid:list" /> <Icon icon="fa7-solid:list" />
Instructions Instructions
</h2> </h2>
<p className="text-xs text-amber-800 mb-3">
All instructions are based off of the default Male Mii.
<br />
{mii.isFromSaveFile && "If you're on modded/emulator, you can download the .ltd file above."}
</p>
{mii.youtubeId && ( {mii.youtubeId && (
<iframe <iframe

View file

@ -58,13 +58,14 @@ export default function SubmitPage() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH"); const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE"); const [gender, setGender] = useState<MiiGender>("MALE");
const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL"); const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL");
const [way, setWay] = useState<"savedata" | "manual" | null>(null);
const [miiDataFile, setMiiDataFile] = useState<File | undefined>();
const [youtubeId, setYouTubeId] = useState(""); const [youtubeId, setYouTubeId] = useState("");
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions); const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name); const nameValidation = nameSchema.safeParse(name);
if (!nameValidation.success) { if (!nameValidation.success) {
setError(nameValidation.error.issues[0].message); setError(nameValidation.error.issues[0].message);
@ -76,32 +77,26 @@ export default function SubmitPage() {
return; return;
} }
// Send request to server
const formData = new FormData(); const formData = new FormData();
formData.append("platform", platform); formData.append("platform", platform);
formData.append("name", name); formData.append("name", name);
formData.append("tags", JSON.stringify(tags)); formData.append("tags", JSON.stringify(tags));
formData.append("description", description); formData.append("description", description);
formData.append("youtubeId", youtubeId);
files.forEach((file, index) => { files.forEach((file, index) => {
// image1, image2, etc.
formData.append(`image${index + 1}`, file); formData.append(`image${index + 1}`, file);
}); });
if (platform === "THREE_DS") { if (platform === "THREE_DS") {
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
} else if (platform === "SWITCH") { } else if (platform === "SWITCH" && way) {
const portraitResponse = await fetch(miiPortraitUri!); const portraitResponse = await fetch(miiPortraitUri!);
const featuresResponse = await fetch(miiFeaturesUri!); if (!portraitResponse.ok) {
setError("Failed to get Mii portrait screenshot. Did you upload one?");
if (!portraitResponse.ok || !featuresResponse.ok) {
setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
return; return;
} }
const portraitBlob = await portraitResponse.blob(); const portraitBlob = await portraitResponse.blob();
const featuresBlob = await featuresResponse.blob(); if (!portraitBlob.type.startsWith("image/")) {
if (!portraitBlob.type.startsWith("image/") || !featuresBlob.type.startsWith("image/")) {
setError("Invalid image file found"); setError("Invalid image file found");
return; return;
} }
@ -109,8 +104,32 @@ export default function SubmitPage() {
formData.append("gender", gender); formData.append("gender", gender);
formData.append("makeup", makeup); formData.append("makeup", makeup);
formData.append("miiPortraitImage", portraitBlob); formData.append("miiPortraitImage", portraitBlob);
formData.append("way", way);
const featuresResponse = await fetch(miiFeaturesUri!);
if (!featuresResponse.ok) {
setError("Failed to get Mii features screenshot. Did you upload one?");
return;
}
const featuresBlob = await featuresResponse.blob();
if (!featuresBlob.type.startsWith("image/")) {
setError("Invalid image file found");
return;
}
if (way === "savedata") {
if (!miiDataFile) {
setError("Failed to find Mii data file, did you upload one?");
return;
}
formData.append("miiDataFile", miiDataFile);
}
formData.append("miiFeaturesImage", featuresBlob); formData.append("miiFeaturesImage", featuresBlob);
formData.append("instructions", JSON.stringify(instructions.current)); formData.append("instructions", JSON.stringify(instructions.current));
formData.append("youtubeId", youtubeId);
} }
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/submit`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/submit`, {
@ -121,7 +140,7 @@ export default function SubmitPage() {
const { id, error } = await response.json(); const { id, error } = await response.json();
if (!response.ok) { if (!response.ok) {
setError(String(error)); // app can crash if error message is not a string setError(String(error));
return; return;
} }
@ -129,19 +148,17 @@ export default function SubmitPage() {
}; };
useEffect(() => { useEffect(() => {
if (platform === "SWITCH" || qrBytesRaw.length == 0) return; if (platform !== "THREE_DS" || qrBytesRaw.length === 0) return;
const qrBytes = new Uint8Array(qrBytesRaw); const qrBytes = new Uint8Array(qrBytesRaw);
const preview = async () => { const preview = async () => {
setError(""); setError("");
// Validate QR code size
if (qrBytesRaw.length !== 372) { if (qrBytesRaw.length !== 372) {
setError("QR code size is not a valid Tomodachi Life QR code"); setError("QR code size is not a valid Tomodachi Life QR code");
return; return;
} }
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii }; let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii };
try { try {
conversion = convertQrCode(qrBytes); conversion = convertQrCode(qrBytes);
@ -151,13 +168,11 @@ export default function SubmitPage() {
return; return;
} }
// Generate a new QR code for aesthetic reasons
try { try {
const byteString = String.fromCharCode(...qrBytes); const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L"); const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte"); generatedCode.addData(byteString, "Byte");
generatedCode.make(); generatedCode.make();
setGeneratedQrCodeUri(generatedCode.createDataURL()); setGeneratedQrCodeUri(generatedCode.createDataURL());
} catch { } catch {
setError("Failed to regenerate QR code"); setError("Failed to regenerate QR code");
@ -194,7 +209,6 @@ export default function SubmitPage() {
</span> </span>
))} ))}
</div> </div>
<div className="mt-auto"> <div className="mt-auto">
<LikeButton likes={0} isLiked={false} disabled /> <LikeButton likes={0} isLiked={false} disabled />
</div> </div>
@ -209,7 +223,6 @@ export default function SubmitPage() {
<p className="text-sm text-zinc-500">Share your creation for others to see.</p> <p className="text-sm text-zinc-500">Share your creation for others to see.</p>
</div> </div>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Info</span> <span>Info</span>
@ -222,33 +235,23 @@ export default function SubmitPage() {
Platform Platform
</label> </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"> <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 <div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${ className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full" platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
}`} }`}
></div> />
{/* Switch button */}
<button <button
type="button" type="button"
onClick={() => setPlatform("SWITCH")} onClick={() => setPlatform("SWITCH")}
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${ 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!"}`}
platform === "SWITCH" && "text-slate-800!"
}`}
> >
<Icon icon="cib:nintendo-switch" className="text-2xl" /> <Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch Switch
</button> </button>
{/* 3DS button */}
<button <button
type="button" type="button"
onClick={() => setPlatform("THREE_DS")} onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${ 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!"}`}
platform === "THREE_DS" && "text-slate-800!"
}`}
> >
<Icon icon="cib:nintendo-3ds" className="text-2xl" /> <Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS 3DS
@ -307,43 +310,34 @@ export default function SubmitPage() {
onClick={() => setGender("MALE")} onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis" aria-label="Filter for Male Miis"
data-tooltip="Male" 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! ${ 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"}`}
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" /> <Icon icon="foundation:male" className="text-blue-400" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setGender("FEMALE")} onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis" aria-label="Filter for Female Miis"
data-tooltip="Female" 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! ${ 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"}`}
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" /> <Icon icon="foundation:female" className="text-pink-400" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setGender("NONBINARY")} onClick={() => setGender("NONBINARY")}
aria-label="Filter for Nonbinary Miis" aria-label="Filter for Nonbinary Miis"
data-tooltip="Nonbinary" 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! ${ 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"}`}
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" /> <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button> </button>
</div> </div>
</div> </div>
{/* Makeup (switch only) */} {/* Makeup (switch only) — unchanged from base */}
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}> <div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
<label className="font-semibold py-2">Face Paint</label> <label className="font-semibold py-2">Face Paint</label>
<div className="col-span-2 flex flex-col gap-1.5"> <div className="col-span-2 flex flex-col gap-1.5">
{[ {[
{ value: "FULL", label: "Full", desc: "Most of the face/features are covered", color: "pink" }, { value: "FULL", label: "Full", desc: "Most of the face/features are covered", color: "pink" },
@ -365,9 +359,34 @@ export default function SubmitPage() {
</div> </div>
</div> </div>
{/* (Switch Only) Mii Screenshots */} {/* (Switch only) Choose a Way */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}> <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>Choose a Way</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 w-full">
<button
onClick={() => 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)
</button>
<button
onClick={() => 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
</button>
</div>
<p className="text-xs text-zinc-400 text-center mt-2">To see a tutorial, select a method above and click the 'How to?' buttons that appear.</p>
</div>
{/* (Switch only) Mii Screenshots */}
<div className={`${platform === "SWITCH" && way ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Mii Screenshots</span> <span>Mii Screenshots</span>
@ -396,7 +415,7 @@ export default function SubmitPage() {
</div> </div>
{/* Step 2 - Features */} {/* Step 2 - Features */}
<div className="flex flex-col items-center gap-2 w-full"> <div className="flex flex-col items-center gap-2 w-full mt-4">
<div className="flex items-center gap-2 self-start"> <div className="flex items-center gap-2 self-start">
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">2</span> <span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">2</span>
<span className="text-sm font-semibold text-zinc-600"> <span className="text-sm font-semibold text-zinc-600">
@ -415,12 +434,27 @@ export default function SubmitPage() {
</div> </div>
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} forceCrop /> <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} forceCrop />
</div> </div>
<SwitchSubmitTutorialButton type="manual" />
<p className="text-xs text-zinc-400 text-center">A tutorial on how to screenshot the features is above.</p>
</div> </div>
<SwitchSubmitTutorialButton />
</div> </div>
</div>
<p className="text-xs text-zinc-400 text-center mt-2">A tutorial on how to screenshot the features is above.</p> {/* (Switch only) ShareMii file upload */}
<div className={`${platform === "SWITCH" && way === "savedata" ? "" : "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>ShareMii File</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<SwitchFileUpload type="file" text="your Mii's .ltd file" file={miiDataFile} setFile={setMiiDataFile} />
<SwitchSubmitTutorialButton type="savedata" />
<p className="text-xs text-zinc-400 text-center">Only the v3 format is supported, please make sure ShareMii is up to date.</p>
<p className="text-xs text-zinc-400 text-center mb-2">
Unfortunately, at this time we can't automatically generate instructions from a .ltd file.
</p>
</div>
</div> </div>
{/* (3DS only) QR code scanning */} {/* (3DS only) QR code scanning */}
@ -430,33 +464,27 @@ export default function SubmitPage() {
<span>QR Code</span> <span>QR Code</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span> <span>or</span>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2"> <button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} /> <Icon icon="mdi:camera" fontSize={20} />
Use your camera Use your camera
</button> </button>
<Camera isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} /> <Camera isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<ThreeDsScanTutorialButton /> <ThreeDsScanTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span> <span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div> </div>
</div> </div>
{/* (Switch only) Mii instructions */} {/* (Switch only) Mii Instructions */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}> <div className={`${platform === "SWITCH" && way ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Mii Instructions</span> <span>Mii Instructions</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{/* YouTube */}
<div className="w-full grid grid-cols-3 items-center"> <div className="w-full grid grid-cols-3 items-center">
<label htmlFor="youtube" className="font-semibold"> <label htmlFor="youtube" className="font-semibold">
YouTube Video YouTube Video
@ -476,40 +504,37 @@ export default function SubmitPage() {
}} }}
/> />
</div> </div>
<MiiEditor instructions={instructions} /> <MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton /> <SwitchSubmitTutorialButton type="manual" />
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8"> <span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
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.
</span> </span>
</div> </div>
</div> </div>
{/* Custom images selector */} {/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2"> <div className={`${platform === "THREE_DS" || way ? "" : "hidden"} flex flex-col justify-center`}>
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<span>Custom images</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Custom images</span>
<hr className="grow border-zinc-300" />
</div>
<div className="max-w-md w-full self-center flex flex-col items-center">
<Dropzone onDrop={handleDrop}>
<p className="text-center text-sm">
Drag and drop your images here
<br />
or click to open
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />
</div> </div>
<div className="max-w-md w-full self-center flex flex-col items-center">
<Dropzone onDrop={handleDrop}>
<p className="text-center text-sm">
Drag and drop your images here
<br />
or click to open
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />
<hr className="border-zinc-300 my-2" /> <hr className="border-zinc-300 my-2" />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>} {error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" /> <SubmitButton onClick={handleSubmit} className="ml-auto" />
</div> </div>
</div> </div>

View file

@ -9,5 +9,5 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.30.3" "packageManager": "pnpm@10.33.2"
} }

View file

@ -25,6 +25,9 @@ importers:
bit-buffer: bit-buffer:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0 version: 0.3.0
charinfo-ex:
specifier: ^0.0.5
version: 0.0.5
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.20 version: 1.11.20
@ -219,6 +222,15 @@ importers:
bit-buffer: bit-buffer:
specifier: ^0.3.0 specifier: ^0.3.0
version: 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: sjcl-with-all:
specifier: 1.0.8 specifier: 1.0.8
version: 1.0.8 version: 1.0.8
@ -1541,6 +1553,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
charinfo-ex@0.0.5:
resolution: {integrity: sha512-GQTTcRfLPbrK5lY+fRqAq1mQUIP7H1EpAO3/I6Kv5t7TditmI6NkGg8nWbRP8hb8JKFZe1oJqAumJWfLYTma1w==}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@ -1998,6 +2013,9 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fzstd@0.1.1:
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
generator-function@2.0.1: generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4249,6 +4267,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
charinfo-ex@0.0.5: {}
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@ -4900,6 +4920,8 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
fzstd@0.1.1: {}
generator-function@2.0.1: {} generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}

View file

@ -4,6 +4,9 @@
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.3.0", "@2toad/profanity": "^3.3.0",
"bit-buffer": "^0.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", "sjcl-with-all": "1.0.8",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

133
shared/src/deswizzle.ts Normal file
View file

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

View file

@ -2,5 +2,6 @@ export * from "./constants";
export * from "./qr-codes"; export * from "./qr-codes";
export * from "./switch"; export * from "./switch";
export * from "./three-ds-tomodachi-life-mii"; export * from "./three-ds-tomodachi-life-mii";
export * from "./switch-tomodachi-life-mii";
export * from "./utils"; export * from "./utils";
export type { SwitchMiiInstructions, MiiGender, MiiMakeup, MiiPlatform, ReportReason } from "./types"; export type { SwitchMiiInstructions, MiiGender, MiiMakeup, MiiPlatform, ReportReason } from "./types";

View file

@ -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(), 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(), 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(), 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(), 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(), quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
// Pages // Pages

View file

@ -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<Buffer | null> {
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<SwitchMiiInstructions> = {
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);
}
}

View file

@ -28,37 +28,40 @@ export function minifyInstructions(instructions: Partial<SwitchMiiInstructions>)
} }
export const defaultInstructions: SwitchMiiInstructions = { export const defaultInstructions: SwitchMiiInstructions = {
head: { skinColor: null }, head: { type: null, skinColor: null },
hair: { hair: {
set: null,
bangs: null,
back: null,
color: null, color: null,
subColor: null, subColor: null,
subColor2: null, subColor2: null,
style: null, style: null,
isFlipped: false, 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: { eyes: {
main: { color: null, 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: { height: null, distance: null, rotation: null, size: null, stretch: null }, eyelashesTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelashesBottom: { height: null, distance: null, rotation: null, size: null, stretch: null }, eyelashesBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidTop: { height: null, distance: null, rotation: null, size: null, stretch: null }, eyelidTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidBottom: { height: null, distance: null, rotation: null, size: null, stretch: null }, eyelidBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyeliner: { color: null }, eyeliner: { type: false, color: null },
pupil: { height: null, distance: null, rotation: null, size: null, stretch: null }, pupil: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
}, },
nose: { height: null, size: null }, nose: { type: null, height: null, size: null },
lips: { color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false }, lips: { type: null, color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
ears: { height: null, size: null }, ears: { type: null, height: null, size: null },
glasses: { ringColor: null, shadesColor: null, height: null, size: null, stretch: null }, glasses: { type: null, type2: null, ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
other: { other: {
wrinkles1: { height: null, distance: null, size: null, stretch: null }, wrinkles1: { type: null, height: null, distance: null, size: null, stretch: null },
wrinkles2: { height: null, distance: null, size: null, stretch: null }, wrinkles2: { type: null, height: null, distance: null, size: null, stretch: null },
beard: { color: null }, beard: { type: null, color: null },
moustache: { color: null, height: null, isFlipped: false, size: null, stretch: null }, moustache: { type: null, color: null, height: null, isFlipped: false, size: null, stretch: null },
goatee: { color: null }, goatee: { type: null, color: null },
mole: { color: null, height: null, distance: null, size: null }, mole: { type: false, height: null, distance: null, size: null },
eyeShadow: { color: null, height: null, distance: null, size: null, stretch: null }, eyeShadow: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
blush: { 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, height: null,
weight: null, weight: null,

50
shared/src/types.d.ts vendored
View file

@ -5,9 +5,15 @@ type ReportReason = "INAPPROPRIATE" | "SPAM" | "BAD_QUALITY" | "OTHER";
export interface SwitchMiiInstructions { export interface SwitchMiiInstructions {
head: { head: {
type: number | null;
skinColor: number | null; // Additional 14 are not in color menu, default is 2 skinColor: number | null; // Additional 14 are not in color menu, default is 2
}; };
hair: { hair: {
set: number | null;
bangs: number | null;
back: number | null;
color: number | null; color: number | null;
subColor: number | null; // Default is none subColor: number | null; // Default is none
subColor2: number | null; // Only used when bangs/back is selected subColor2: number | null; // Only used when bangs/back is selected
@ -15,6 +21,8 @@ export interface SwitchMiiInstructions {
isFlipped: boolean; // Only for sets and fringe isFlipped: boolean; // Only for sets and fringe
}; };
eyebrows: { eyebrows: {
type: number | null;
color: number | null; color: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
@ -24,6 +32,8 @@ export interface SwitchMiiInstructions {
}; };
eyes: { eyes: {
main: { main: {
type: number | null;
color: number | null; color: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
@ -32,6 +42,8 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
eyelashesTop: { eyelashesTop: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
rotation: number | null; rotation: number | null;
@ -39,6 +51,8 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
eyelashesBottom: { eyelashesBottom: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
rotation: number | null; rotation: number | null;
@ -46,6 +60,8 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
eyelidTop: { eyelidTop: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
rotation: number | null; rotation: number | null;
@ -53,6 +69,8 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
eyelidBottom: { eyelidBottom: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
rotation: number | null; rotation: number | null;
@ -60,9 +78,12 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
eyeliner: { eyeliner: {
type: boolean;
color: number | null; color: number | null;
}; };
pupil: { pupil: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
rotation: number | null; rotation: number | null;
@ -71,10 +92,14 @@ export interface SwitchMiiInstructions {
}; };
}; };
nose: { nose: {
type: number | null;
height: number | null; height: number | null;
size: number | null; size: number | null;
}; };
lips: { lips: {
type: number | null;
color: number | null; color: number | null;
height: number | null; height: number | null;
rotation: number | null; rotation: number | null;
@ -83,10 +108,15 @@ export interface SwitchMiiInstructions {
hasLipstick: boolean; hasLipstick: boolean;
}; };
ears: { ears: {
type: number | null;
height: number | null; // Does not work for default height: number | null; // Does not work for default
size: number | null; // Does not work for default size: number | null; // Does not work for default
}; };
glasses: { glasses: {
type: number | null;
type2: number | null;
ringColor: number | null; ringColor: number | null;
shadesColor: number | null; // Only works after gap shadesColor: number | null; // Only works after gap
height: number | null; height: number | null;
@ -96,37 +126,50 @@ export interface SwitchMiiInstructions {
other: { other: {
// names were assumed // names were assumed
wrinkles1: { wrinkles1: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
size: number | null; size: number | null;
stretch: number | null; stretch: number | null;
}; };
wrinkles2: { wrinkles2: {
type: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
size: number | null; size: number | null;
stretch: number | null; stretch: number | null;
}; };
beard: { beard: {
type: number | null;
color: number | null; color: number | null;
}; };
moustache: { moustache: {
color: number | null; // is this same as hair? type: number | null;
color: number | null;
height: number | null; height: number | null;
isFlipped: boolean; isFlipped: boolean;
size: number | null; size: number | null;
stretch: number | null; stretch: number | null;
}; };
goatee: { goatee: {
type: number | null;
color: number | null; color: number | null;
}; };
mole: { mole: {
color: number | null; // is this same as hair? type: boolean;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
size: number | null; size: number | null;
}; };
eyeShadow: { eyeShadow: {
type: number | null;
color: number | null; color: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
@ -134,6 +177,8 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
blush: { blush: {
type: number | null;
color: number | null; color: number | null;
height: number | null; height: number | null;
distance: number | null; distance: number | null;
@ -141,7 +186,6 @@ export interface SwitchMiiInstructions {
stretch: number | null; stretch: number | null;
}; };
}; };
// makeup, use video?
height: number | null; height: number | null;
weight: number | null; weight: number | null;
datingPreferences: MiiGender[]; datingPreferences: MiiGender[];