diff --git a/backend/package.json b/backend/package.json index f45b104..4662f36 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,9 @@ "@2toad/profanity": "^3.3.0", "@auth/prisma-adapter": "2.11.1", "@prisma/client": "^6.19.2", + "@tomodachi-share/shared": "workspace:*", "bit-buffer": "^0.3.0", + "charinfo-ex": "^0.0.5", "dayjs": "^1.11.20", "downshift": "^9.3.2", "file-type": "^22.0.1", @@ -27,8 +29,7 @@ "satori": "^0.26.0", "sharp": "^0.34.5", "sjcl-with-all": "1.0.8", - "zod": "^4.3.6", - "@tomodachi-share/shared": "workspace:*" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", diff --git a/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql b/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql new file mode 100644 index 0000000..7c831e6 --- /dev/null +++ b/backend/prisma/migrations/20260425172523_save_file_boolean/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2cda82c..0faf492 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -79,10 +79,11 @@ model Mii { in_queue Boolean @default(false) needsFixing String? - instructions Json? - youtubeId String? - gender MiiGender? - makeup MiiMakeup? + isFromSaveFile Boolean @default(false) + instructions Json? + youtubeId String? + gender MiiGender? + makeup MiiMakeup? firstName String? lastName String? diff --git a/backend/src/app/api/mii/list/route.ts b/backend/src/app/api/mii/list/route.ts index 8f5451b..f1d2d44 100644 --- a/backend/src/app/api/mii/list/route.ts +++ b/backend/src/app/api/mii/list/route.ts @@ -19,6 +19,7 @@ export async function GET(request: NextRequest) { makeup, allowCopying, quarantined, + isFromSaveFile, page = 1, limit = 24, parentPage, @@ -59,17 +60,13 @@ export async function GET(request: NextRequest) { // Tag filtering ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), - // Platform + // Other ...(platform && { platform: { equals: platform } }), - // Gender ...(gender && { gender: { equals: gender } }), - // Allow Copying ...(allowCopying && { allowedCopying: true }), - // Makeup ...(makeup && { makeup: { equals: makeup } }), - // Quarantined ...(!quarantined && !userId && { quarantined: false }), - // Time range + ...(isFromSaveFile && { isFromSaveFile: true }), ...(timeRange && { reviewedAt: { gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]), diff --git a/backend/src/app/api/submit/route.ts b/backend/src/app/api/submit/route.ts index 178d776..53b64e3 100644 --- a/backend/src/app/api/submit/route.ts +++ b/backend/src/app/api/submit/route.ts @@ -15,60 +15,56 @@ import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi- import { RateLimit } from "@/lib/rate-limit"; import { generateMetadataImage, validateImage } from "@/lib/images"; import Mii from "../../../../../shared/src/mii.js/mii"; -import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared"; +import { convertQrCode, minifyInstructions, SwitchTomodachiLifeMii, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared"; import { SwitchMiiInstructions } from "@tomodachi-share/shared"; import { settings } from "../../../lib/settings"; +import { CharInfoEx } from "charinfo-ex"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); -const submitSchema = z - .object({ - platform: z.enum(MiiPlatform).default("THREE_DS"), - name: nameSchema, - tags: tagsSchema, - description: z.string().trim().max(512).optional(), +const submitSchema = z.object({ + platform: z.enum(MiiPlatform).default("THREE_DS"), + name: nameSchema, + tags: tagsSchema, + description: z.string().trim().max(512).optional(), - // Switch - gender: z.enum(MiiGender).default("MALE"), - makeup: z.enum(MiiMakeup).default("PARTIAL"), - miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), - miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), - youtubeId: z - .string() - .trim() - .transform((val) => (val === "" ? null : val)) - .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID") - .optional(), - instructions: switchMiiInstructionsSchema, + // Switch + gender: z.enum(MiiGender).default("MALE"), + makeup: z.enum(MiiMakeup).default("PARTIAL"), + miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + youtubeId: z + .string() + .trim() + .transform((val) => (val === "" ? null : val)) + .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID") + .optional(), - // QR code - qrBytesRaw: z - .array(z.number(), { error: "A QR code is required" }) - .length(372, { - error: "QR code size is not a valid Tomodachi Life QR code", - }) - .nullish(), + way: z.enum(["savedata", "manual"]).optional(), - // Custom images - image1: z.union([z.instanceof(File), z.any()]).optional(), - image2: z.union([z.instanceof(File), z.any()]).optional(), - image3: z.union([z.instanceof(File), z.any()]).optional(), - }) - // This refine function is probably useless - .refine( - (data) => { - // If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present - if (data.platform === "SWITCH") { - return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined; - } - return true; - }, - { - message: "Gender, Mii portrait & features image are required for Switch platform", - path: ["gender", "miiPortraitImage", "miiFeaturesImage"], - }, - ); + // Save data way + miiDataFile: z + .instanceof(File) + .refine((blob) => blob.size < 1024 * 1024 * 0.1, "File too large") // TODO: actual size + .optional(), + + // Manual way + miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), + instructions: switchMiiInstructionsSchema, + + // QR code + qrBytesRaw: z + .array(z.number(), { error: "A QR code is required" }) + .length(372, { + error: "QR code size is not a valid Tomodachi Life QR code", + }) + .nullish(), + + // Custom images + image1: z.union([z.instanceof(File), z.any()]).optional(), + image2: z.union([z.instanceof(File), z.any()]).optional(), + image3: z.union([z.instanceof(File), z.any()]).optional(), +}); export async function POST(request: NextRequest) { const session = await auth(); @@ -106,8 +102,12 @@ export async function POST(request: NextRequest) { gender: formData.get("gender") ?? undefined, // ZOD MOMENT makeup: formData.get("makeup") ?? undefined, miiPortraitImage: formData.get("miiPortraitImage"), - miiFeaturesImage: formData.get("miiFeaturesImage"), youtubeId: formData.get("youtubeId"), + way: formData.get("way"), + + miiDataFile: formData.get("miiDataFile") ?? undefined, + + miiFeaturesImage: formData.get("miiFeaturesImage"), instructions: minifiedInstructions, qrBytesRaw: rawQrBytesRaw, @@ -131,6 +131,8 @@ export async function POST(request: NextRequest) { qrBytesRaw, gender, makeup, + way, + miiDataFile, miiPortraitImage, miiFeaturesImage, youtubeId, @@ -161,9 +163,10 @@ export async function POST(request: NextRequest) { // Check Mii portrait & features image (Switch) if (platform === "SWITCH") { const portraitValidation = await validateImage(miiPortraitImage); - const featuresValidation = await validateImage(miiFeaturesImage); if (!portraitValidation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400); + + const featuresValidation = await validateImage(miiFeaturesImage); if (!featuresValidation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400); } @@ -180,6 +183,21 @@ export async function POST(request: NextRequest) { } } + const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined; + const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined; + + let parsedSwitchMii: SwitchTomodachiLifeMii | undefined = undefined; + + if (way === "savedata") { + if (!miiData || !miiDataFileBuffer) return rateLimit.sendResponse({ error: "No valid Mii data provided" }, 400); + try { + parsedSwitchMii = new SwitchTomodachiLifeMii(miiDataFileBuffer, miiData); + } catch (error) { + console.warn("Failed to verify Switch Mii data", error); + return rateLimit.sendResponse({ error: "Failed to verify Mii data: is your ShareMii file up to date?" }, 400); + } + } + // Create Mii in database const miiRecord = await prisma.mii.create({ data: { @@ -204,6 +222,7 @@ export async function POST(request: NextRequest) { youtubeId, instructions: minifiedInstructions, makeup: makeup ?? "PARTIAL", + ...(way === "savedata" && { isFromSaveFile: true }), }), }, }); @@ -228,18 +247,20 @@ export async function POST(request: NextRequest) { } else if (platform === "SWITCH") { portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer()); - // Save features image const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer()); - const pngBuffer = await sharp(featuresBuffer) - .resize({ - height: 800, - fit: "inside", - withoutEnlargement: true, - }) - .png({ quality: 85 }) - .toBuffer(); - const fileLocation = path.join(miiUploadsDirectory, "features.png"); - await fs.writeFile(fileLocation, pngBuffer); + const pngBuffer = await sharp(featuresBuffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer(); + await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer); + + if (way === "savedata" && miiDataFileBuffer) { + await fs.writeFile(path.join(miiUploadsDirectory, "data.ltd"), Buffer.from(miiDataFileBuffer)); + + if (parsedSwitchMii) { + const pngBuffer = await parsedSwitchMii.extractFacePaintImage(); + if (pngBuffer) await fs.writeFile(path.join(miiUploadsDirectory, "facepaint.png"), pngBuffer); + } else { + return rateLimit.sendResponse({ error: "Failed to extract Switch Mii data" }, 500); + } + } } // Save portrait image diff --git a/backend/src/app/mii/[id]/download/route.ts b/backend/src/app/mii/[id]/download/route.ts new file mode 100644 index 0000000..3a93a7a --- /dev/null +++ b/backend/src/app/mii/[id]/download/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; + +import fs from "fs/promises"; +import path from "path"; + +import { prisma } from "@/lib/prisma"; +import { RateLimit } from "@/lib/rate-limit"; +import { idSchema } from "@tomodachi-share/shared/schemas"; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const rateLimit = new RateLimit(request, 4, "/mii/download"); + const check = await rateLimit.handle(); + if (check) return check; + + const { id: slugId } = await params; + const parsed = idSchema.safeParse(slugId); + if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); + const miiId = parsed.data; + + const mii = await prisma.mii.findUnique({ + where: { id: miiId }, + }); + if (!mii) return new NextResponse("Not found", { status: 404 }); + + try { + const buffer = await fs.readFile(path.join(process.cwd(), "uploads", "mii", miiId.toString(), "data.ltd")); + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${mii.name}.ltd"`, + }, + }); + } catch { + return rateLimit.sendResponse({ error: "File not found" }, 404); + } +} diff --git a/backend/src/app/mii/[id]/image/route.ts b/backend/src/app/mii/[id]/image/route.ts index 21b1782..2b7784c 100644 --- a/backend/src/app/mii/[id]/image/route.ts +++ b/backend/src/app/mii/[id]/image/route.ts @@ -12,8 +12,8 @@ import { prisma } from "@/lib/prisma"; const searchParamsSchema = z.object({ type: z - .enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], { - message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'", + .enum(["mii", "qr-code", "features", "facepaint", "image0", "image1", "image2", "metadata"], { + message: "Image type must be either 'mii', 'qr-code', 'features', 'facepaint', 'image[number from 0 to 2]' or 'metadata'", }) .default("mii"), }); diff --git a/frontend/public/tutorial/switch/adding-mii/step1.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step1.jpg similarity index 100% rename from frontend/public/tutorial/switch/adding-mii/step1.jpg rename to frontend/public/tutorial/switch/adding-mii/manual/step1.jpg diff --git a/frontend/public/tutorial/switch/adding-mii/step2.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step2.jpg similarity index 100% rename from frontend/public/tutorial/switch/adding-mii/step2.jpg rename to frontend/public/tutorial/switch/adding-mii/manual/step2.jpg diff --git a/frontend/public/tutorial/switch/adding-mii/step3.png b/frontend/public/tutorial/switch/adding-mii/manual/step3.png similarity index 100% rename from frontend/public/tutorial/switch/adding-mii/step3.png rename to frontend/public/tutorial/switch/adding-mii/manual/step3.png diff --git a/frontend/public/tutorial/switch/adding-mii/step4.jpg b/frontend/public/tutorial/switch/adding-mii/manual/step4.jpg similarity index 100% rename from frontend/public/tutorial/switch/adding-mii/step4.jpg rename to frontend/public/tutorial/switch/adding-mii/manual/step4.jpg diff --git a/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png b/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png new file mode 100644 index 0000000..1471fbd Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png differ diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg b/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg new file mode 100644 index 0000000..a7bacef Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step1.jpg differ diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step2.png b/frontend/public/tutorial/switch/adding-mii/modded/step2.png new file mode 100644 index 0000000..ea36cd9 Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step2.png differ diff --git a/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg b/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg new file mode 100644 index 0000000..9cad088 Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/step3.jpg differ diff --git a/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png b/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png new file mode 100644 index 0000000..3a05302 Binary files /dev/null and b/frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png differ diff --git a/frontend/src/components/dropzone.tsx b/frontend/src/components/dropzone.tsx index cd0c5be..4d67715 100644 --- a/frontend/src/components/dropzone.tsx +++ b/frontend/src/components/dropzone.tsx @@ -1,49 +1,48 @@ -import { type ReactNode, useState } from "react"; -import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone"; -import { Icon } from "@iconify/react"; - -interface Props { - onDrop: (acceptedFiles: FileWithPath[]) => void; - options?: DropzoneOptions; - children?: ReactNode; -} - -export default function Dropzone({ onDrop, options, children }: Props) { - const [isDraggingOver, setIsDraggingOver] = useState(false); - - const handleDrop = (acceptedFiles: FileWithPath[]) => { - setIsDraggingOver(false); - onDrop(acceptedFiles); - }; - - const { getRootProps, getInputProps } = useDropzone({ - onDrop: handleDrop, - maxFiles: 3, - accept: { - "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"], - }, - ...options, - }); - - return ( -
setIsDraggingOver(true)} - onDragLeave={() => setIsDraggingOver(false)} - className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${ - isDraggingOver && "scale-105 brightness-90 shadow-xl" - }`} - > - {/* Used to transition from border-dashed to border-solid */} -
- - 1 : false })} /> - - {children} -
- ); -} +import { type ReactNode, useState } from "react"; +import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone"; +import { Icon } from "@iconify/react"; + +interface Props { + type?: "file" | "image"; + onDrop: (acceptedFiles: FileWithPath[]) => void; + options?: DropzoneOptions; + children?: ReactNode; +} + +export default function Dropzone({ type = "image", onDrop, options, children }: Props) { + const [isDraggingOver, setIsDraggingOver] = useState(false); + + const handleDrop = (acceptedFiles: FileWithPath[]) => { + setIsDraggingOver(false); + onDrop(acceptedFiles); + }; + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: handleDrop, + maxFiles: 3, + accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] }, + ...options, + }); + + return ( +
setIsDraggingOver(true)} + onDragLeave={() => setIsDraggingOver(false)} + className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${ + isDraggingOver && "scale-105 brightness-90 shadow-xl" + }`} + > + {/* Used to transition from border-dashed to border-solid */} +
+ + 1 : false })} /> + + {children} +
+ ); +} diff --git a/frontend/src/components/image-viewer.tsx b/frontend/src/components/image-viewer.tsx index c05257e..e927d20 100644 --- a/frontend/src/components/image-viewer.tsx +++ b/frontend/src/components/image-viewer.tsx @@ -1,165 +1,165 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import useEmblaCarousel from "embla-carousel-react"; -import { Icon } from "@iconify/react"; - -interface Props { - src: string; - alt: string; - width: number; - height: number; - className?: string; - images?: string[]; -} - -export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) { - const [isOpen, setIsOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); - - const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 }); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollSnaps, setScrollSnaps] = useState([]); - - const close = () => { - setIsVisible(false); - setTimeout(() => { - setIsOpen(false); - }, 300); - }; - - useEffect(() => { - if (isOpen) { - // slight delay to trigger animation - setTimeout(() => setIsVisible(true), 10); - } - }, [isOpen]); - - useEffect(() => { - if (!emblaApi) return; - - // Keep order of images whilst opening at src prop - const index = images.indexOf(src); - if (index !== -1) { - emblaApi.scrollTo(index, true); - setSelectedIndex(index); - } - - setScrollSnaps(emblaApi.scrollSnapList()); - emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); - }, [emblaApi, images, src]); - - // Handle keyboard events - useEffect(() => { - if (!isOpen || !emblaApi) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowLeft") emblaApi.scrollPrev(); - else if (event.key === "ArrowRight") emblaApi.scrollNext(); - else if (event.key === "Escape") close(); - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [isOpen, emblaApi]); - - const imagesMap = images.length === 0 ? [src] : images; - - return ( - <> - {/* not inserting pixelated image-rendering here because i thought it looked a bit weird */} - {alt} setIsOpen(true)} className={`cursor-pointer ${className}`} /> - - {isOpen && - createPortal( -
-
- - - -
-
- {imagesMap.map((image, index) => ( -
- {alt} -
- ))} -
-
- - {images.length > 1 && ( - <> - {/* Carousel counter */} -
- {selectedIndex + 1} / {images.length} -
- - {/* Carousel buttons */} - {/* Prev button */} - - {/* Next button */} - - - {/* Carousel snaps */} -
- {scrollSnaps.map((_, index) => ( -
- - )} -
, - document.body, - )} - - ); -} +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import useEmblaCarousel from "embla-carousel-react"; +import { Icon } from "@iconify/react"; + +interface Props { + src: string; + alt: string; + width: number; + height: number; + className?: string; + images?: string[]; +} + +export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 }); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollSnaps, setScrollSnaps] = useState([]); + + const close = () => { + setIsVisible(false); + setTimeout(() => { + setIsOpen(false); + }, 300); + }; + + useEffect(() => { + if (isOpen) { + // slight delay to trigger animation + setTimeout(() => setIsVisible(true), 10); + } + }, [isOpen]); + + useEffect(() => { + if (!emblaApi) return; + + // Keep order of images whilst opening at src prop + const index = images.indexOf(src); + if (index !== -1) { + emblaApi.scrollTo(index, true); + setSelectedIndex(index); + } + + setScrollSnaps(emblaApi.scrollSnapList()); + emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); + }, [emblaApi, images, src]); + + // Handle keyboard events + useEffect(() => { + if (!isOpen || !emblaApi) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") emblaApi.scrollPrev(); + else if (event.key === "ArrowRight") emblaApi.scrollNext(); + else if (event.key === "Escape") close(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, emblaApi]); + + const imagesMap = images.length === 0 ? [src] : images; + + return ( + <> + {/* not inserting pixelated image-rendering here because i thought it looked a bit weird */} + {alt} setIsOpen(true)} className={`cursor-pointer ${className}`} /> + + {isOpen && + createPortal( +
+
+ + + +
+
+ {imagesMap.map((image, index) => ( +
+ {alt} +
+ ))} +
+
+ + {images.length > 1 && ( + <> + {/* Carousel counter */} +
+ {selectedIndex + 1} / {images.length} +
+ + {/* Carousel buttons */} + {/* Prev button */} + + {/* Next button */} + + + {/* Carousel snaps */} +
+ {scrollSnaps.map((_, index) => ( +
+ + )} +
, + document.body, + )} + + ); +} diff --git a/frontend/src/components/mii/instructions.tsx b/frontend/src/components/mii/instructions.tsx index bd0d4b1..65c1239 100644 --- a/frontend/src/components/mii/instructions.tsx +++ b/frontend/src/components/mii/instructions.tsx @@ -1,254 +1,254 @@ -import type { ReactNode } from "react"; -import DatingPreferencesViewer from "./dating-preferences"; -import PersonalityViewer from "./personality-viewer"; - -import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared"; - -interface Props { - instructions: Partial; -} - -interface SectionProps { - name: string; - instructions: Partial; - children?: ReactNode; - isSubSection?: boolean; -} - -const ORDINAL_SUFFIXES: Record = { - one: "st", - two: "nd", - few: "rd", - other: "th", -}; -const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); - -function not(value: any) { - return value !== undefined && value !== null; -} - -function numberValue(value: number, cutoff: number = 25) { - return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`; -} - -function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) { - const row = Math.floor(index / cols) + 1; - const col = (index % cols) + 1; - const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)]; - const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)]; - - return `${row}${rowSuffix} row, ${col}${colSuffix} column`; -} - -function ColorPosition({ color }: { color: number | undefined | null }) { - if (color === undefined || color === null) return null; - if (color <= 7) { - return ( - -
- Color menu on left, -
- ); - } - if (color >= 108) { - return ( - -
- Outside color menu, -
- ); - } - - return ( - -
- Color menu on right, -
- ); -} - -interface TableCellProps { - label: string; - children: React.ReactNode; -} - -function TableCell({ label, children }: TableCellProps) { - return ( - - {label} - {children} - - ); -} - -function Section({ name, instructions, children, isSubSection }: SectionProps) { - if (typeof instructions !== "object" || !instructions) return null; - - const color = "color" in instructions ? instructions.color : undefined; - const height = "height" in instructions ? instructions.height : undefined; - const distance = "distance" in instructions ? instructions.distance : undefined; - const rotation = "rotation" in instructions ? instructions.rotation : undefined; - const size = "size" in instructions ? instructions.size : undefined; - const stretch = "stretch" in instructions ? instructions.stretch : undefined; - - return ( -
-

{name}

- - - - {not(color) && ( - - - - )} - {not(height) && {numberValue(height!, 0)}} - {not(distance) && {numberValue(distance!, 0)}} - {not(rotation) && {numberValue(rotation!, 0)}} - {not(size) && {numberValue(size!, 0)}} - {not(stretch) && {numberValue(stretch!, 0)}} - - {children} - -
-
- ); -} - -export default function MiiInstructions({ instructions }: Props) { - if (Object.keys(instructions).length === 0) return null; - const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions; - - return ( - <> - {head && ( -
- {not(head.skinColor) && ( - - - - )} -
- )} - {hair && ( -
- {not(hair.subColor) && ( - - - - )} - {not(hair.subColor2) && ( - - - - )} - {not(hair.style) && {hair.style}} - {not(hair.isFlipped) && {hair.isFlipped ? "Yes" : "No"}} -
- )} - {eyebrows &&
} - {eyes && ( -
-
-
-
-
-
-
-
-
- )} - {nose &&
} - {lips && ( -
- {not(lips.hasLipstick) && {lips.hasLipstick ? "Yes" : "No"}} -
- )} - {ears &&
} - {glasses && ( -
- {not(glasses.ringColor) && ( - - - - )} - {not(glasses.shadesColor) && ( - - - - )} -
- )} - {other && ( -
-
-
-
-
- {other.moustache && other.moustache.isFlipped && {other.moustache.isFlipped ? "Yes" : "No"}} -
-
-
-
-
-
- )} - - {(height || weight || datingPreferences || voice || personality) && ( -
-

Misc

- - - - {not(height) && {numberValue(height!, 64)}} - {not(weight) && {numberValue(weight!, 64)}} - -
- {birthday && ( -
-

Birthday

- - - {not(birthday.day) && {birthday.day}} - {not(birthday.month) && {birthday.month}} - {not(birthday.age) && {birthday.age}} - {not(birthday.dontAge) && {birthday.dontAge ? "Yes" : "No"}} - -
-
- )} - {voice && ( -
-

Voice

- - - {not(voice.speed) && {numberValue(voice.speed!, 25)}} - {not(voice.pitch) && {numberValue(voice.pitch!, 25)}} - {not(voice.depth) && {numberValue(voice.depth!, 25)}} - {not(voice.delivery) && {numberValue(voice.delivery!, 25)}} - {not(voice.tone) && {voice.tone}} - -
-
- )} - {datingPreferences && ( -
-

Dating Preferences

-
- -
-
- )} - {personality && ( -
-

Personality

-
- -
-
- )} -
- )} - - ); -} +import type { ReactNode } from "react"; +import DatingPreferencesViewer from "./dating-preferences"; +import PersonalityViewer from "./personality-viewer"; + +import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared"; + +interface Props { + instructions: Partial; +} + +interface SectionProps { + name: string; + instructions: Partial; + children?: ReactNode; + isSubSection?: boolean; +} + +const ORDINAL_SUFFIXES: Record = { + one: "st", + two: "nd", + few: "rd", + other: "th", +}; +const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); + +function not(value: any) { + return value !== undefined && value !== null; +} + +function numberValue(value: number, cutoff: number = 25) { + return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`; +} + +function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) { + const row = Math.floor(index / cols) + 1; + const col = (index % cols) + 1; + const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)]; + const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)]; + + return `${row}${rowSuffix} row, ${col}${colSuffix} column`; +} + +function ColorPosition({ color }: { color: number | undefined | null }) { + if (color === undefined || color === null) return null; + if (color <= 7) { + return ( + +
+ Color menu on left, +
+ ); + } + if (color >= 108) { + return ( + +
+ Outside color menu, +
+ ); + } + + return ( + +
+ Color menu on right, +
+ ); +} + +interface TableCellProps { + label: string; + children: React.ReactNode; +} + +function TableCell({ label, children }: TableCellProps) { + return ( + + {label} + {children} + + ); +} + +function Section({ name, instructions, children, isSubSection }: SectionProps) { + if (typeof instructions !== "object" || !instructions) return null; + + const color = "color" in instructions ? instructions.color : undefined; + const height = "height" in instructions ? instructions.height : undefined; + const distance = "distance" in instructions ? instructions.distance : undefined; + const rotation = "rotation" in instructions ? instructions.rotation : undefined; + const size = "size" in instructions ? instructions.size : undefined; + const stretch = "stretch" in instructions ? instructions.stretch : undefined; + + return ( +
+

{name}

+ + + + {not(color) && ( + + + + )} + {not(height) && {numberValue(height!, 0)}} + {not(distance) && {numberValue(distance!, 0)}} + {not(rotation) && {numberValue(rotation!, 0)}} + {not(size) && {numberValue(size!, 0)}} + {not(stretch) && {numberValue(stretch!, 0)}} + + {children} + +
+
+ ); +} + +export default function MiiInstructions({ instructions }: Props) { + if (Object.keys(instructions).length === 0) return null; + const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions; + + return ( + <> + {head && ( +
+ {not(head.skinColor) && ( + + + + )} +
+ )} + {hair && ( +
+ {not(hair.subColor) && ( + + + + )} + {not(hair.subColor2) && ( + + + + )} + {not(hair.style) && {hair.style}} + {not(hair.isFlipped) && {hair.isFlipped ? "Yes" : "No"}} +
+ )} + {eyebrows &&
} + {eyes && ( +
+
+
+
+
+
+
+
+
+ )} + {nose &&
} + {lips && ( +
+ {not(lips.hasLipstick) && {lips.hasLipstick ? "Yes" : "No"}} +
+ )} + {ears &&
} + {glasses && ( +
+ {not(glasses.ringColor) && ( + + + + )} + {not(glasses.shadesColor) && ( + + + + )} +
+ )} + {other && ( +
+
+
+
+
+ {other.moustache && other.moustache.isFlipped && {other.moustache.isFlipped ? "Yes" : "No"}} +
+
+
+
+
+
+ )} + + {(height || weight || datingPreferences || voice || personality) && ( +
+

Misc

+ + + + {not(height) && {numberValue(height!, 64)}} + {not(weight) && {numberValue(weight!, 64)}} + +
+ {birthday && ( +
+

Birthday

+ + + {not(birthday.day) && {birthday.day}} + {not(birthday.month) && {birthday.month}} + {not(birthday.age) && {birthday.age}} + {not(birthday.dontAge) && {birthday.dontAge ? "Yes" : "No"}} + +
+
+ )} + {voice && ( +
+

Voice

+ + + {not(voice.speed) && {numberValue(voice.speed!, 25)}} + {not(voice.pitch) && {numberValue(voice.pitch!, 25)}} + {not(voice.depth) && {numberValue(voice.depth!, 25)}} + {not(voice.delivery) && {numberValue(voice.delivery!, 25)}} + {not(voice.tone) && {voice.tone}} + +
+
+ )} + {datingPreferences && ( +
+

Dating Preferences

+
+ +
+
+ )} + {personality && ( +
+

Personality

+
+ +
+
+ )} +
+ )} + + ); +} diff --git a/frontend/src/components/mii/list/other-filters.tsx b/frontend/src/components/mii/list/other-filters.tsx index 3da9ebc..f3a124a 100644 --- a/frontend/src/components/mii/list/other-filters.tsx +++ b/frontend/src/components/mii/list/other-filters.tsx @@ -9,9 +9,27 @@ export default function OtherFilters() { const [, startTransition] = useTransition(); const platform = (searchParams.get("platform") as MiiPlatform) || undefined; + const [hasShareMiiFile, setHasShareMiiFile] = useState((searchParams.get("sharemii") as unknown as boolean) ?? false); const [allowCopying, setAllowCopying] = useState((searchParams.get("allowCopying") as unknown as boolean) ?? false); const [quarantined, setQuarantined] = useState((searchParams.get("quarantined") as unknown as boolean) ?? false); + const handleChangeHasShareMiiFile = (e: ChangeEvent) => { + setHasShareMiiFile(e.target.checked); + + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + + if (!hasShareMiiFile) { + params.set("isFromSaveFile", "true"); + } else { + params.delete("isFromSaveFile"); + } + + startTransition(() => { + navigate(`?${params.toString()}`); + }); + }; + const handleChangeAllowCopying = (e: ChangeEvent) => { setAllowCopying(e.target.checked); @@ -59,6 +77,14 @@ export default function OtherFilters() {
+ {platform !== "THREE_DS" && ( +
+ + +
+ )} {showAllowCopying && (
{/* Buttons */} -
+
+ {mii.isFromSaveFile && ( + + + Download + + )} @@ -353,11 +393,16 @@ export default function MiiPage() { {/* Instructions */} {mii.platform === "SWITCH" && ( -
+

Instructions

+

+ All instructions are based off of the default Male Mii. +
+ {mii.isFromSaveFile && "If you're on modded/emulator, you can download the .ltd file above."} +

{mii.youtubeId && (