feat: exclusively using .ltd file and partial color map

This commit is contained in:
trafficlunar 2026-04-12 00:16:26 +01:00
parent 1ec0b73712
commit 79e6adff64
13 changed files with 917 additions and 963 deletions

View file

@ -16,20 +16,20 @@
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@sentry/nextjs": "^10.46.0", "@sentry/nextjs": "^10.48.0",
"bit-buffer": "^0.3.0", "bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"charinfo-ex": "^0.0.2", "charinfo-ex": "^0.0.5",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^22.0.0", "file-type": "^22.0.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "16.2.1", "next": "16.2.3",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.4", "react": "^19.2.5",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"redis": "^5.11.0", "redis": "^5.11.0",
@ -45,13 +45,13 @@
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.5.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^10.1.0", "eslint": "^10.2.0",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.3",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"schema-dts": "^2.0.0", "schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",

File diff suppressed because it is too large Load diff

View file

@ -1,2 +1,2 @@
-- AlterTable -- AlterTable
ALTER TABLE "miis" ADD COLUMN "miiData" JSONB; ALTER TABLE "miis" ADD COLUMN "miiData" BYTEA;

View file

@ -83,7 +83,7 @@ model Mii {
gender MiiGender? gender MiiGender?
makeup MiiMakeup? makeup MiiMakeup?
miiData Json? miiData Bytes?
firstName String? firstName String?
lastName String? lastName String?

View file

@ -46,7 +46,10 @@ const submitSchema = z.object({
way: z.enum(["savedata", "manual"]).optional(), way: z.enum(["savedata", "manual"]).optional(),
// Save data way // Save data way
// TODO: miiData miiDataFile: z
.instanceof(File)
.refine((blob) => blob.size < 1024 * 30, "File too large") // TODO: actual size
.optional(),
// Manual way // Manual way
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
@ -109,6 +112,7 @@ export async function POST(request: NextRequest) {
youtubeId: formData.get("youtubeId"), youtubeId: formData.get("youtubeId"),
way: formData.get("way"), way: formData.get("way"),
miiDataFile: formData.get("miiDataFile"),
miiFeaturesImage: formData.get("miiFeaturesImage"), miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions, instructions: minifiedInstructions,
@ -146,6 +150,7 @@ export async function POST(request: NextRequest) {
miiPortraitImage, miiPortraitImage,
miiFeaturesImage, miiFeaturesImage,
way, way,
miiDataFile,
youtubeId, youtubeId,
image1, image1,
image2, image2,
@ -197,14 +202,13 @@ export async function POST(request: NextRequest) {
} }
} }
const miiData: CharInfoEx | undefined = const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined;
way === "savedata" && formData.get("miiData") ? (JSON.parse(formData.get("miiData") as string) as CharInfoEx) : undefined; const miiDataFileArray = miiDataFileBuffer ? new Uint8Array(miiDataFileBuffer) : undefined;
const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined;
if (way === "savedata" && !miiData) { if (way === "savedata") {
return rateLimit.sendResponse({ error: "No mii data provided" }, 400); if (!miiData) return rateLimit.sendResponse({ error: "No mii data provided" }, 400);
}
if (way === "savedata" && miiData) {
const instructions: Partial<SwitchMiiInstructions> = { const instructions: Partial<SwitchMiiInstructions> = {
head: { head: {
type: miiData.facelineType, type: miiData.facelineType,
@ -257,12 +261,12 @@ export async function POST(request: NextRequest) {
stretch: miiData.eyelashLowerAspect, stretch: miiData.eyelashLowerAspect,
}, },
eyelidTop: { eyelidTop: {
type: miiData.eyeLidUpperType, type: miiData.eyelidUpperType,
height: miiData.eyeLidUpperY, height: miiData.eyelidUpperY,
distance: miiData.eyeLidUpperX, distance: miiData.eyelidUpperX,
rotation: miiData.eyeLidUpperRotate, rotation: miiData.eyelidUpperRotate,
size: miiData.eyeLidUpperScale, size: miiData.eyelidUpperScale,
stretch: miiData.eyeLidUpperAspect, stretch: miiData.eyelidUpperAspect,
}, },
eyelidBottom: { eyelidBottom: {
type: miiData.eyelidLowerType, type: miiData.eyelidLowerType,
@ -316,14 +320,14 @@ export async function POST(request: NextRequest) {
}, },
other: { other: {
wrinkles1: { wrinkles1: {
type: miiData.wrinkleLower, type: miiData.wrinkleLowerType,
height: miiData.wrinkleLowerY, height: miiData.wrinkleLowerY,
distance: miiData.wrinkleLowerX, distance: miiData.wrinkleLowerX,
size: miiData.wrinkleLowerScale, size: miiData.wrinkleLowerScale,
stretch: miiData.wrinkleLowerAspect, stretch: miiData.wrinkleLowerAspect,
}, },
wrinkles2: { wrinkles2: {
type: miiData.wrinkleUpper, type: miiData.wrinkleUpperType,
height: miiData.wrinkleUpperY, height: miiData.wrinkleUpperY,
distance: miiData.wrinkleUpperX, distance: miiData.wrinkleUpperX,
size: miiData.wrinkleUpperScale, size: miiData.wrinkleUpperScale,
@ -401,7 +405,7 @@ export async function POST(request: NextRequest) {
youtubeId, youtubeId,
makeup: makeup ?? "PARTIAL", makeup: makeup ?? "PARTIAL",
instructions: minifiedInstructions, instructions: minifiedInstructions,
...(way === "savedata" && { miiData: miiData?.toJson() }), ...(way === "savedata" && { miiData: miiDataFileArray }),
}), }),
}, },
}); });

View file

@ -402,7 +402,7 @@ export default async function MiiPage({ params }: Props) {
></iframe> ></iframe>
)} )}
<MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} /> <MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} isUsingSaveFile={mii.miiData !== null} />
</div> </div>
)} )}
</div> </div>

View file

@ -13,7 +13,7 @@ export default function Description({ text, className }: Props) {
return ( return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}> <p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{parts.map(async (part, index) => { {parts.map((part, index) => {
try { try {
// Check if it's a URL // Check if it's a URL
if (!urlRegex.test(part)) throw new Error("Not a URL"); if (!urlRegex.test(part)) throw new Error("Not a URL");

View file

@ -22,7 +22,7 @@ export default function Dropzone({ type = "image", onDrop, options, children }:
const { getRootProps, getInputProps } = useDropzone({ const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop, onDrop: handleDrop,
maxFiles: 3, maxFiles: 3,
accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".sav"] }, accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] },
...options, ...options,
}); });

View file

@ -2,14 +2,14 @@ import React from "react";
import Image from "next/image"; import Image from "next/image";
import DatingPreferencesViewer from "./dating-preferences"; import DatingPreferencesViewer from "./dating-preferences";
import VoiceViewer from "./voice-viewer";
import PersonalityViewer from "./personality-viewer"; import PersonalityViewer from "./personality-viewer";
import { SwitchMiiInstructions } from "@/types"; import { SwitchMiiInstructions } from "@/types";
import { COLORS } from "@/lib/switch"; import { COLOR_MAP, COLORS } from "@/lib/switch";
interface Props { interface Props {
instructions: Partial<SwitchMiiInstructions>; instructions: Partial<SwitchMiiInstructions>;
isUsingSaveFile: boolean;
} }
interface SectionProps { interface SectionProps {
@ -19,6 +19,7 @@ interface SectionProps {
pad?: number; // Number of digits to pad with zeroes pad?: number; // Number of digits to pad with zeroes
children?: React.ReactNode; children?: React.ReactNode;
isSubSection?: boolean; isSubSection?: boolean;
isUsingSaveFile: boolean;
} }
const ORDINAL_SUFFIXES: Record<string, string> = { const ORDINAL_SUFFIXES: Record<string, string> = {
@ -46,29 +47,35 @@ function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
return `${row}${rowSuffix} row, ${col}${colSuffix} column`; return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
} }
function ColorPosition({ color }: { color: number | undefined | null }) { function ColorPosition({ color, isUsingSaveFile }: { color: number | undefined | null; isUsingSaveFile: boolean }) {
if (color === undefined || color === null) return null; if (color === undefined || color === null) return null;
if (color <= 7) {
const index = isUsingSaveFile ? COLOR_MAP[color] : color;
if (index === undefined) return null;
console.log(index, color, COLORS[color]);
if (index <= 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[index]}` }}></div>
Color menu on left, <GridPosition index={color} cols={1} /> Color menu on left, <GridPosition index={index} cols={1} />
</span> </span>
); );
} }
if (color >= 108) { if (index >= 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[index]}` }}></div>
Outside color menu, <GridPosition index={color - 108} cols={2} /> Outside color menu, <GridPosition index={index - 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[index]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} /> Color menu on right, <GridPosition index={index - 8} cols={10} />
</span> </span>
); );
} }
@ -87,7 +94,7 @@ function TableCell({ label, children }: TableCellProps) {
); );
} }
function Section({ name, iconTemplate, pad = 2, instructions, children, isSubSection }: SectionProps) { function Section({ name, iconTemplate, pad = 2, instructions, children, isSubSection, isUsingSaveFile }: SectionProps) {
if (typeof instructions !== "object" || !instructions) return null; if (typeof instructions !== "object" || !instructions) return null;
const type = "type" in instructions ? instructions.type : undefined; const type = "type" in instructions ? instructions.type : undefined;
@ -102,7 +109,7 @@ function Section({ name, iconTemplate, pad = 2, instructions, children, isSubSec
const isBooleanType = typeof type === "boolean"; const isBooleanType = typeof type === "boolean";
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 mb-4"}`}> <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"}`}>
<h3 className={`font-semibold text-amber-800 mb-1 ${isSubSection ? "text-lg" : "text-xl"}`}>{name}</h3> <h3 className={`font-semibold text-amber-800 mb-1 ${isSubSection ? "text-lg" : "text-xl"}`}>{name}</h3>
<table className="w-full"> <table className="w-full">
@ -117,7 +124,7 @@ function Section({ name, iconTemplate, pad = 2, instructions, children, isSubSec
{not(color) && ( {not(color) && (
<TableCell label="Color"> <TableCell label="Color">
<ColorPosition color={color} /> <ColorPosition color={color} isUsingSaveFile={isUsingSaveFile} />
</TableCell> </TableCell>
)} )}
{not(height) && <TableCell label="Height">{numberValue(height, 0)}</TableCell>} {not(height) && <TableCell label="Height">{numberValue(height, 0)}</TableCell>}
@ -133,27 +140,27 @@ function Section({ name, iconTemplate, pad = 2, instructions, children, isSubSec
); );
} }
export default function MiiInstructions({ instructions }: Props) { export default function MiiInstructions({ instructions, isUsingSaveFile }: 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" iconTemplate="Faceline%s_Uit" instructions={head}> <Section name="Head" iconTemplate="Faceline%s_Uit" instructions={head} isUsingSaveFile={isUsingSaveFile}>
{not(head.skinColor) && ( {not(head.skinColor) && (
<TableCell label="Skin Color"> <TableCell label="Skin Color">
<ColorPosition color={head.skinColor} /> <ColorPosition color={head.skinColor} isUsingSaveFile={isUsingSaveFile} />
</TableCell> </TableCell>
)} )}
</Section> </Section>
)} )}
{hair && ( {hair && (
<Section name="Hair" iconTemplate="HairAll%s_Uit" pad={3} instructions={hair}> <Section name="Hair" iconTemplate="HairAll%s_Uit" pad={3} instructions={hair} isUsingSaveFile={isUsingSaveFile}>
{not(hair.set) && ( {not(hair.set) && (
<TableCell label="Set"> <TableCell label="Set">
<Image src={`/icons/MiiEditor_Face_HairAll${String(hair.set).padStart(3, "0")}_Uit.png`} width={64} height={64} alt="icon" /> <Image src={`/icons/MiiEditor_Face_Hair${String(hair.set).padStart(3, "0")}_Uit.png`} width={64} height={64} alt="icon" />
</TableCell> </TableCell>
)} )}
{not(hair.bangs) && ( {not(hair.bangs) && (
@ -168,12 +175,12 @@ export default function MiiInstructions({ instructions }: Props) {
)} )}
{not(hair.subColor) && ( {not(hair.subColor) && (
<TableCell label="Sub Color"> <TableCell label="Sub Color">
<ColorPosition color={hair.subColor} /> <ColorPosition color={hair.subColor} isUsingSaveFile={isUsingSaveFile} />
</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} isUsingSaveFile={isUsingSaveFile} />
</TableCell> </TableCell>
)} )}
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>} {not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
@ -181,64 +188,64 @@ export default function MiiInstructions({ instructions }: Props) {
</Section> </Section>
)} )}
{eyebrows && <Section name="Eyebrows" iconTemplate="Eyebrow%s_Uit" instructions={eyebrows}></Section>} {eyebrows && <Section name="Eyebrows" iconTemplate="Eyebrow%s_Uit" instructions={eyebrows} isUsingSaveFile={isUsingSaveFile} />}
{eyes && ( {eyes && (
<Section name="Eyes" instructions={eyes}> <Section name="Eyes" instructions={eyes} isUsingSaveFile={isUsingSaveFile}>
<Section isSubSection name="Main" iconTemplate="Eye%s_Uit" pad={3} instructions={eyes.main} /> <Section isSubSection name="Main" iconTemplate="Eye%s_Uit" pad={3} instructions={eyes.main} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Eyelashes (Top)" iconTemplate="Eyelash%s_Uit" instructions={eyes.eyelashesTop} /> <Section isSubSection name="Eyelashes (Top)" iconTemplate="Eyelash%s_Uit" instructions={eyes.eyelashesTop} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Eyelashes (Bottom)" iconTemplate="EyelashLower%s_Uit" instructions={eyes.eyelashesBottom} /> <Section
<Section isSubSection name="Eyelid (Top)" iconTemplate="EyelidUpper%s_Uit" instructions={eyes.eyelidTop} /> isSubSection
<Section isSubSection name="Eyelid (Bottom)" iconTemplate="EyelidLower%s_Uit" instructions={eyes.eyelidBottom} /> name="Eyelashes (Bottom)"
<Section isSubSection name="Eyeliner" instructions={eyes.eyeliner} /> iconTemplate="EyelashLower%s_Uit"
<Section isSubSection name="Pupil" iconTemplate="EyeHighlight%s_Uit" instructions={eyes.pupil} /> instructions={eyes.eyelashesBottom}
isUsingSaveFile={isUsingSaveFile}
/>
<Section isSubSection name="Eyelid (Top)" iconTemplate="EyelidUpper%s_Uit" instructions={eyes.eyelidTop} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Eyelid (Bottom)" iconTemplate="EyelidLower%s_Uit" instructions={eyes.eyelidBottom} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Eyeliner" instructions={eyes.eyeliner} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Pupil" iconTemplate="EyeHighlight%s_Uit" instructions={eyes.pupil} isUsingSaveFile={isUsingSaveFile} />
</Section> </Section>
)} )}
{nose && <Section name="Nose" iconTemplate="Nose%s_Uit" instructions={nose}></Section>} {nose && <Section name="Nose" iconTemplate="Nose%s_Uit" instructions={nose} isUsingSaveFile={isUsingSaveFile} />}
{lips && ( {lips && (
<Section name="Lips" iconTemplate="Mouth%s_Uit" pad={3} instructions={lips}> <Section name="Lips" iconTemplate="Mouth%s_Uit" pad={3} instructions={lips} isUsingSaveFile={isUsingSaveFile}>
{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" iconTemplate="Ear%s_Uit" instructions={ears}></Section>} {ears && <Section name="Ears" iconTemplate="Ear%s_Uit" instructions={ears} isUsingSaveFile={isUsingSaveFile} />}
{glasses && ( {glasses && (
<Section name="Glasses" iconTemplate="Glass%scolor_Uit" instructions={glasses}> <Section name="Glasses" iconTemplate="Glass%scolor_Uit" instructions={glasses} isUsingSaveFile={isUsingSaveFile}>
{not(glasses.type2) && <TableCell label="Type 2">{glasses.type2}</TableCell>} {not(glasses.type2) && <TableCell label="Type 2">{glasses.type2}</TableCell>}
{not(glasses.ringColor) && ( {not(glasses.ringColor) && (
<TableCell label="Ring Color"> <TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} /> <ColorPosition color={glasses.ringColor} isUsingSaveFile={isUsingSaveFile} />
</TableCell> </TableCell>
)} )}
{not(glasses.shadesColor) && ( {not(glasses.shadesColor) && (
<TableCell label="Shades Color"> <TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} /> <ColorPosition color={glasses.shadesColor} isUsingSaveFile={isUsingSaveFile} />
</TableCell> </TableCell>
)} )}
</Section> </Section>
)} )}
{other && ( {other && (
<Section name="Other" instructions={other}> <Section name="Other" instructions={other} isUsingSaveFile={isUsingSaveFile}>
<Section isSubSection name="Wrinkles 1" iconTemplate="WrinkleLower%s_Uit" instructions={other.wrinkles1} /> <Section isSubSection name="Wrinkles 1" iconTemplate="WrinkleLower%s_Uit" instructions={other.wrinkles1} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Wrinkles 2" iconTemplate="WrinkleUpper%s_Uit" instructions={other.wrinkles2} /> <Section isSubSection name="Wrinkles 2" iconTemplate="WrinkleUpper%s_Uit" instructions={other.wrinkles2} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Beard" iconTemplate="Beard%s_Uit" instructions={other.beard} /> <Section isSubSection name="Beard" iconTemplate="Beard%s_Uit" instructions={other.beard} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Moustache" iconTemplate="Mustache%s_Uit" instructions={other.moustache}> <Section isSubSection name="Moustache" iconTemplate="Mustache%s_Uit" instructions={other.moustache} isUsingSaveFile={isUsingSaveFile}>
{other.moustache && other.moustache.isFlipped !== undefined && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>} {other.moustache && other.moustache.isFlipped !== undefined && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section> </Section>
<Section isSubSection name="Goatee" iconTemplate="BeardShort%s_Uit" instructions={other.goatee} /> <Section isSubSection name="Goatee" iconTemplate="BeardShort%s_Uit" instructions={other.goatee} isUsingSaveFile={isUsingSaveFile} />
<Section isSubSection name="Mole" instructions={other.mole}> <Section isSubSection name="Mole" instructions={other.mole} isUsingSaveFile={isUsingSaveFile} />
{other.mole?.type && ( <Section isSubSection name="Eye Shadow" iconTemplate="MakeUpper%s_Uit" instructions={other.eyeShadow} isUsingSaveFile={isUsingSaveFile} />
<TableCell label="Icon"> <Section isSubSection name="Blush" iconTemplate="MakeLower%s_Uit" instructions={other.blush} isUsingSaveFile={isUsingSaveFile} />
<Image src={`/icons/MiiEditor_Face_Mole00_Uit.png`} width={64} height={64} alt="icon" />
</TableCell>
)}
</Section>
<Section isSubSection name="Eye Shadow" iconTemplate="MakeUpper%s_Uit" instructions={other.eyeShadow} />
<Section isSubSection name="Blush" iconTemplate="MakeLower%s_Uit" instructions={other.blush} />
</Section> </Section>
)} )}

View file

@ -7,7 +7,7 @@ import { Icon } from "@iconify/react";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
image: string | undefined; image: string | File | undefined;
setImage: (value: string | undefined) => void; setImage: (value: string | undefined) => void;
} }

View file

@ -61,9 +61,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL"); const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL");
const [way, setWay] = useState<"savedata" | "manual" | null>(null); const [way, setWay] = useState<"savedata" | "manual" | null>(null);
const [miiSaveFileBytes, setMiiSaveFileBytes] = useState<ArrayBufferLike | undefined>(); const [miiDataFile, setMiiDataFile] = useState<File | undefined>();
const [miis, setMiis] = useState<CharInfoEx[]>([]);
const [selectedMiiIndex, setSelectedMiiIndex] = useState(0);
const [youtubeId, setYouTubeId] = useState(""); const [youtubeId, setYouTubeId] = useState("");
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions); const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
@ -114,7 +112,11 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
formData.append("miiPortraitImage", portraitBlob); formData.append("miiPortraitImage", portraitBlob);
formData.append("way", way); formData.append("way", way);
if (way === "savedata") { if (way === "savedata") {
formData.append("miiData", JSON.stringify(miis[selectedMiiIndex])); if (!miiDataFile) {
setError("Failed to find Mii data file, did you upload one?");
return;
}
formData.append("miiDataFile", miiDataFile);
} else { } else {
const featuresResponse = await fetch(miiFeaturesUri!); const featuresResponse = await fetch(miiFeaturesUri!);
if (!featuresResponse.ok) { if (!featuresResponse.ok) {
@ -150,19 +152,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
}; };
useEffect(() => { useEffect(() => {
if (platform === "SWITCH") { if (platform !== "THREE_DS") return;
if (!miiSaveFileBytes) return;
const miis: CharInfoEx[] = [];
for (let i = 0; i < 70; i++) {
const data = CharInfoEx.FromSaveFileArrayBuffer(miiSaveFileBytes, i);
if (data.name === "") continue;
miis.push(data);
}
setMiis(miis);
return;
}
if (qrBytesRaw.length == 0) return; if (qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw); const qrBytes = new Uint8Array(qrBytesRaw);
@ -199,7 +189,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
}; };
preview(); preview();
}, [miiSaveFileBytes, qrBytesRaw, platform]); }, [qrBytesRaw, platform]);
return ( return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
@ -233,7 +223,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
</div> </div>
</div> </div>
<div className="max-w-2xl"> <div className="max-w-2xl w-full">
{inQueueMiisCount !== 0 && ( {inQueueMiisCount !== 0 && (
<div className="bg-zinc-50 border-2 border-zinc-400 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-zinc-600 mb-4"> <div className="bg-zinc-50 border-2 border-zinc-400 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-zinc-600 mb-4">
<Icon icon="material-symbols:timer" className="text-2xl shrink-0" /> <Icon icon="material-symbols:timer" className="text-2xl shrink-0" />
@ -443,7 +433,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
type="button" 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"}`} 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"}`}
> >
Save Data (no makeup yet) .ltd file
</button> </button>
<button <button
@ -455,6 +445,8 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
Manual Manual
</button> </button>
</div> </div>
<p className="text-xs text-zinc-400 text-center mt-2">Click on a way to see tutorials for them</p>
</div> </div>
{/* (Switch Only) Mii Screenshots */} {/* (Switch Only) Mii Screenshots */}
@ -483,7 +475,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70" className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
/> />
</div> </div>
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop /> <SwitchFileUpload text="a screenshot of your Mii here" file={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
</div> </div>
</div> </div>
@ -505,7 +497,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70" className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
/> />
</div> </div>
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} /> <SwitchFileUpload text="a screenshot of your Mii's features here" file={miiFeaturesUri} setImage={setMiiFeaturesUri} />
</div> </div>
</div> </div>
@ -551,6 +543,8 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<SwitchFileUpload type="file" text="your Mii's .ltd file" file={miiDataFile} setFile={setMiiDataFile} />
{/* YouTube */} {/* 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">
@ -571,27 +565,6 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
}} }}
/> />
</div> </div>
<SwitchFileUpload type="file" text="your Mii.sav file" setFileBytes={setMiiSaveFileBytes} />
<p className="text-sm text-center">Choose the Mii you want to submit:</p>
<div className="relative bg-orange-100 border-2 border-orange-300 rounded-lg max-w-md w-full p-1 flex flex-col h-48 overflow-x-auto">
{miis?.length === 0 ? (
<p className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 text-center text-sm">No miis found, upload a save file!</p>
) : (
miis?.map((mii, index) => (
<button
type="button"
key={index}
onClick={() => setSelectedMiiIndex(index)}
className={`w-full cursor-pointer text-left px-1.5 py-0.5 rounded-md transition-colors duration-75 ${selectedMiiIndex === index ? "bg-orange-300" : "hover:bg-orange-200"}`}
>
{mii.name}
</button>
))
)}
</div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { FileWithPath } from "react-dropzone"; import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
@ -11,43 +11,37 @@ interface Props {
text: string; text: string;
type?: "file" | "image"; type?: "file" | "image";
forceCrop?: boolean; forceCrop?: boolean;
image?: string | undefined; file?: string | File | undefined;
setFileBytes?: (value: ArrayBufferLike | undefined) => void; setFile?: (value: File | undefined) => void;
setImage?: (value: string | undefined) => void; setImage?: (value: string | undefined) => void;
} }
export default function SwitchFileUpload({ text, type = "image", forceCrop, image, setFileBytes, setImage }: Props) { export default function SwitchFileUpload({ text, type = "image", forceCrop, file, setFile, setImage }: Props) {
const [isCameraOpen, setIsCameraOpen] = useState(false); const [isCameraOpen, setIsCameraOpen] = useState(false);
const [isCropOpen, setIsCropOpen] = useState(false); const [isCropOpen, setIsCropOpen] = useState(false);
const handleDrop = useCallback( const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => { (acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0]; const file = acceptedFiles[0];
const reader = new FileReader(); if (type === "file") {
reader.onload = async (event) => { setFile!(file);
const result = event.target!.result; } else {
const reader = new FileReader();
if (type === "file") { reader.onload = (event) => {
const buffer = result as ArrayBuffer; setImage!(event.target!.result as string);
setFileBytes!(buffer);
} else {
// for images
setImage!(result as string);
if (forceCrop) setIsCropOpen(true); if (forceCrop) setIsCropOpen(true);
} };
}; reader.readAsDataURL(file);
}
if (type === "file") reader.readAsArrayBuffer(file);
else reader.readAsDataURL(file);
}, },
[setFileBytes, setImage], [setFile, setImage],
); );
return ( return (
<div className="max-w-md w-full flex flex-col items-center gap-2"> <div className="max-w-md w-full flex flex-col items-center gap-2">
<Dropzone type={type} onDrop={handleDrop} options={{ maxFiles: 1 }}> <Dropzone type={type} onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm"> <p className="text-center text-sm">
{!image ? ( {!file ? (
<> <>
Drag and drop {text} Drag and drop {text}
<br /> <br />
@ -82,7 +76,7 @@ export default function SwitchFileUpload({ text, type = "image", forceCrop, imag
if (forceCrop) setIsCropOpen(true); if (forceCrop) setIsCropOpen(true);
}} }}
/> />
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage!} /> <ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={file} setImage={setImage!} />
</> </>
)} )}
</div> </div>

View file

@ -28,37 +28,31 @@ 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, subColor: null, subColor2: null, style: null, isFlipped: false },
color: null, eyebrows: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
subColor: null,
subColor2: null,
style: null,
isFlipped: false,
},
eyebrows: { 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,
@ -238,3 +232,26 @@ export const COLORS: string[] = [
"86E1B0", "86E1B0",
"6E44B0", "6E44B0",
]; ];
export const COLOR_MAP: number[] = [
// Row 1
88, 99, 107, 97, 48, 101, 90, 29, 98, 68,
// Row 2
89, 91, 74, 92, 100, 87, 94, 49, 58, 67,
// Row 3
57, 47, 80, 69, 96, 37, 27, 17, 106, 76,
// Row 4
86, 77, 66, 56, 46, 36, 26, 16, 105, 95,
// Row 5
85, 75, 65, 45, 55, 35, 25, 15, 104, 84,
// Row 6
64, 44, 24, 54, 34, 14, 103, 93, 83, 73,
// Row 7
63, 53, 43, 23, 13, 102, 72, 82, 62, 52,
// Row 8
42, 33, 32, 22, 12, 81, 71, 51, 61, 41,
// Row 9
31, 21, 11, 79, 19, 30, 40, 20, 10, 59,
// Row 10
60, 39, 70, 50, 9, 78, 38, 28, 18, 8,
];