feat: .ltd files
no automatic instructions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -79,6 +79,7 @@ model Mii {
|
||||||
in_queue Boolean @default(false)
|
in_queue Boolean @default(false)
|
||||||
needsFixing String?
|
needsFixing String?
|
||||||
|
|
||||||
|
isFromSaveFile Boolean @default(false)
|
||||||
instructions Json?
|
instructions Json?
|
||||||
youtubeId String?
|
youtubeId String?
|
||||||
gender MiiGender?
|
gender MiiGender?
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,15 @@ 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,
|
||||||
|
|
@ -33,13 +33,23 @@ const submitSchema = z
|
||||||
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(),
|
||||||
|
|
||||||
|
way: z.enum(["savedata", "manual"]).optional(),
|
||||||
|
|
||||||
|
// Save data way
|
||||||
|
miiDataFile: z
|
||||||
|
.instanceof(File)
|
||||||
|
.refine((blob) => blob.size < 1024 * 1024 * 0.1, "File too large") // TODO: actual size
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
// Manual way
|
||||||
|
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
instructions: switchMiiInstructionsSchema,
|
instructions: switchMiiInstructionsSchema,
|
||||||
|
|
||||||
// QR code
|
// QR code
|
||||||
|
|
@ -54,21 +64,7 @@ const submitSchema = z
|
||||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
image2: 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(),
|
image3: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
})
|
});
|
||||||
// This refine function is probably useless
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
|
|
||||||
if (data.platform === "SWITCH") {
|
|
||||||
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Gender, Mii portrait & features image are required for Switch platform",
|
|
||||||
path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
36
backend/src/app/mii/[id]/download/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step1.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step3.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
|
@ -3,12 +3,13 @@ import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-drop
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
type?: "file" | "image";
|
||||||
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
||||||
options?: DropzoneOptions;
|
options?: DropzoneOptions;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dropzone({ onDrop, options, children }: Props) {
|
export default function Dropzone({ type = "image", onDrop, options, children }: Props) {
|
||||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
|
||||||
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
||||||
|
|
@ -19,9 +20,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
|
||||||
const { getRootProps, getInputProps } = useDropzone({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
onDrop: handleDrop,
|
onDrop: handleDrop,
|
||||||
maxFiles: 3,
|
maxFiles: 3,
|
||||||
accept: {
|
accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] },
|
||||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
|
|
||||||
},
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ function Section({ name, instructions, children, isSubSection }: SectionProps) {
|
||||||
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">
|
||||||
|
|
@ -188,7 +188,7 @@ export default function MiiInstructions({ instructions }: Props) {
|
||||||
{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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -7,34 +7,40 @@ import ImageEditorPortrait from "./image-editor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
|
type?: "file" | "image";
|
||||||
forceCrop?: boolean;
|
forceCrop?: boolean;
|
||||||
|
file?: string | File | undefined;
|
||||||
|
setFile?: (value: File | undefined) => void;
|
||||||
image?: string | undefined;
|
image?: string | undefined;
|
||||||
setImage: (value: string | undefined) => void;
|
setImage?: (value: string | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
|
export default function SwitchFileUpload({ text, type = "image", forceCrop, file, setFile, image, 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];
|
||||||
// Convert to Data URI
|
if (type === "file") {
|
||||||
|
setFile!(file);
|
||||||
|
} else {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async (event) => {
|
reader.onload = (event) => {
|
||||||
setImage(event.target!.result as string);
|
setImage!(event.target!.result as string);
|
||||||
if (forceCrop) setIsCropOpen(true);
|
if (forceCrop) setIsCropOpen(true);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[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 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 && !image ? (
|
||||||
<>
|
<>
|
||||||
Drag and drop {text}
|
Drag and drop {text}
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -46,6 +52,8 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
||||||
</p>
|
</p>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
|
{type === "image" && (
|
||||||
|
<>
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
|
|
||||||
<div className="flex gap-2 max-sm:flex-col">
|
<div className="flex gap-2 max-sm:flex-col">
|
||||||
|
|
@ -67,7 +75,9 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
||||||
if (forceCrop) setIsCropOpen(true);
|
if (forceCrop) setIsCropOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage!} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +156,13 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{slide.link ? (
|
||||||
|
<a href={slide.link} className="text-sm text-blue-600 mb-2 text-center">
|
||||||
|
{slide.text}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={slide.imageSrc ?? "/missing.svg"}
|
src={slide.imageSrc ?? "/missing.svg"}
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,47 @@ export default function SwitchAddMiiTutorialButton() {
|
||||||
<Tutorial
|
<Tutorial
|
||||||
tutorials={[
|
tutorials={[
|
||||||
{
|
{
|
||||||
title: "Adding Mii",
|
title: "ShareMii (Modded)",
|
||||||
|
thumbnail: "/tutorial/switch/adding-mii/modded/thumbnail.png",
|
||||||
steps: [
|
steps: [
|
||||||
|
{ type: "start" },
|
||||||
|
{
|
||||||
|
text: "1. Download ShareMii - click here for link",
|
||||||
|
link: "https://gamebanana.com/tools/22305",
|
||||||
|
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "2. Download the .ltd file, it is above the instructions next to all the other buttons",
|
||||||
|
imageSrc: "/tutorial/switch/adding-mii/modded/step2.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "3. Follow the instructions by the creator (scroll down to importing) - click here for link",
|
||||||
|
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
||||||
|
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
||||||
|
},
|
||||||
|
{ type: "finish" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Manual",
|
||||||
|
thumbnail: "/tutorial/switch/adding-mii/manual/thumbnail.png",
|
||||||
|
steps: [
|
||||||
|
{ type: "start" },
|
||||||
{
|
{
|
||||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
|
imageSrc: "/tutorial/switch/adding-mii/manual/step1.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "2. Press 'From scratch' and choose the Male template",
|
text: "2. Press 'From scratch' and choose the Male template",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
|
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",
|
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/step3.png",
|
imageSrc: "/tutorial/switch/adding-mii/manual/step3.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
|
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/step4.jpg",
|
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)",
|
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ 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 {
|
||||||
|
type: "savedata" | "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SwitchSubmitTutorialButton({ type }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -17,7 +21,22 @@ export default function SwitchSubmitTutorialButton() {
|
||||||
tutorials={[
|
tutorials={[
|
||||||
{
|
{
|
||||||
title: "Submitting",
|
title: "Submitting",
|
||||||
steps: [
|
steps:
|
||||||
|
type === "savedata"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: "1. Download ShareMii - click here for link",
|
||||||
|
link: "https://gamebanana.com/tools/22305",
|
||||||
|
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "2. Follow the instructions by the creator (scroll down to exporting) - click here for link",
|
||||||
|
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
||||||
|
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
||||||
|
},
|
||||||
|
{ type: "finish" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
text: "1. Press X to open the menu, then select 'Residents'",
|
text: "1. Press X to open the menu, then select 'Residents'",
|
||||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export default function MiiPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={`${API_URL}/mii/${mii.id}/image?type=features`}
|
src={`${API_URL}/mii/${mii.id}/image?type=features`}
|
||||||
alt="mii features"
|
alt="mii features"
|
||||||
|
|
@ -142,6 +143,39 @@ export default function MiiPage() {
|
||||||
height={300}
|
height={300}
|
||||||
className="rounded-lg hover:brightness-90 mb-4 transition-all"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SwitchSubmitTutorialButton />
|
{/* (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>
|
||||||
|
|
||||||
<p className="text-xs text-zinc-400 text-center mt-2">A tutorial on how to screenshot the features is above.</p>
|
|
||||||
</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,22 +504,21 @@ 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={`${platform === "THREE_DS" || way ? "" : "hidden"} flex flex-col justify-center`}>
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Custom images</span>
|
<span>Custom images</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-md w-full self-center flex flex-col items-center">
|
<div className="max-w-md w-full self-center flex flex-col items-center">
|
||||||
<Dropzone onDrop={handleDrop}>
|
<Dropzone onDrop={handleDrop}>
|
||||||
<p className="text-center text-sm">
|
<p className="text-center text-sm">
|
||||||
|
|
@ -500,16 +527,14 @@ export default function SubmitPage() {
|
||||||
or click to open
|
or click to open
|
||||||
</p>
|
</p>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
|
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageList files={files} setFiles={setFiles} />
|
<ImageList files={files} setFiles={setFiles} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,5 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.30.3"
|
"packageManager": "pnpm@10.33.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
277
shared/src/switch-tomodachi-life-mii.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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[];
|
||||||
|
|
|
||||||