diff --git a/prisma/migrations/20250913120915_switch_sequel/migration.sql b/prisma/migrations/20250913120915_switch_sequel/migration.sql new file mode 100644 index 0000000..f16a8bd --- /dev/null +++ b/prisma/migrations/20250913120915_switch_sequel/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS'); + +-- AlterTable +ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS', +ALTER COLUMN "firstName" DROP NOT NULL, +ALTER COLUMN "lastName" DROP NOT NULL, +ALTER COLUMN "islandName" DROP NOT NULL, +ALTER COLUMN "allowedCopying" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7c029bf..2f4f5a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,17 +69,19 @@ model Session { } model Mii { - id Int @id @default(autoincrement()) - userId Int - name String @db.VarChar(64) - imageCount Int @default(0) - tags String[] - description String? @db.VarChar(256) + id Int @id @default(autoincrement()) + userId Int - firstName String - lastName String + name String @db.VarChar(64) + imageCount Int @default(0) + tags String[] + description String? @db.VarChar(256) + platform MiiPlatform @default(THREE_DS) + + firstName String? + lastName String? gender MiiGender? - islandName String + islandName String? allowedCopying Boolean? createdAt DateTime @default(now()) @@ -154,6 +156,11 @@ model Punishment { @@map("punishments") } +enum MiiPlatform { + SWITCH + THREE_DS // can't start with a number +} + enum MiiGender { MALE FEMALE diff --git a/public/tutorial/adding-mii/step2.png b/public/tutorial/adding-mii/step2.png deleted file mode 100644 index 60a1eb9..0000000 Binary files a/public/tutorial/adding-mii/step2.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step3.png b/public/tutorial/adding-mii/step3.png deleted file mode 100644 index df0840c..0000000 Binary files a/public/tutorial/adding-mii/step3.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step4.png b/public/tutorial/adding-mii/step4.png deleted file mode 100644 index 2f21ed9..0000000 Binary files a/public/tutorial/adding-mii/step4.png and /dev/null differ diff --git a/public/tutorial/adding-mii/step5.png b/public/tutorial/adding-mii/step5.png deleted file mode 100644 index 9269d13..0000000 Binary files a/public/tutorial/adding-mii/step5.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step2.png b/public/tutorial/allow-copying/step2.png deleted file mode 100644 index 9c2b295..0000000 Binary files a/public/tutorial/allow-copying/step2.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step3.png b/public/tutorial/allow-copying/step3.png deleted file mode 100644 index 307b5f6..0000000 Binary files a/public/tutorial/allow-copying/step3.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step4.png b/public/tutorial/allow-copying/step4.png deleted file mode 100644 index f91cc5f..0000000 Binary files a/public/tutorial/allow-copying/step4.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step5.png b/public/tutorial/allow-copying/step5.png deleted file mode 100644 index 93b0cf5..0000000 Binary files a/public/tutorial/allow-copying/step5.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step6.png b/public/tutorial/allow-copying/step6.png deleted file mode 100644 index 67bae80..0000000 Binary files a/public/tutorial/allow-copying/step6.png and /dev/null differ diff --git a/public/tutorial/allow-copying/step7.png b/public/tutorial/allow-copying/step7.png deleted file mode 100644 index b30964d..0000000 Binary files a/public/tutorial/allow-copying/step7.png and /dev/null differ diff --git a/public/tutorial/allow-copying/thumbnail.png b/public/tutorial/allow-copying/thumbnail.png deleted file mode 100644 index ab54c2a..0000000 Binary files a/public/tutorial/allow-copying/thumbnail.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step2.png b/public/tutorial/create-qr-code/step2.png deleted file mode 100644 index 60a1eb9..0000000 Binary files a/public/tutorial/create-qr-code/step2.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step3.png b/public/tutorial/create-qr-code/step3.png deleted file mode 100644 index dbc6743..0000000 Binary files a/public/tutorial/create-qr-code/step3.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step4.png b/public/tutorial/create-qr-code/step4.png deleted file mode 100644 index 562b19f..0000000 Binary files a/public/tutorial/create-qr-code/step4.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step5.png b/public/tutorial/create-qr-code/step5.png deleted file mode 100644 index e6cf9fd..0000000 Binary files a/public/tutorial/create-qr-code/step5.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/step6.png b/public/tutorial/create-qr-code/step6.png deleted file mode 100644 index f78da9a..0000000 Binary files a/public/tutorial/create-qr-code/step6.png and /dev/null differ diff --git a/public/tutorial/create-qr-code/thumbnail.png b/public/tutorial/create-qr-code/thumbnail.png deleted file mode 100644 index cfb7906..0000000 Binary files a/public/tutorial/create-qr-code/thumbnail.png and /dev/null differ diff --git a/public/tutorial/step1.png b/public/tutorial/step1.png deleted file mode 100644 index 13983f9..0000000 Binary files a/public/tutorial/step1.png and /dev/null differ diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 85b40ac..87b0e89 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -139,7 +139,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } else if (description === undefined) { // If images or description were not changed, regenerate the metadata image - await generateMetadataImage(updatedMii, updatedMii.user.name!); + try { + await generateMetadataImage(updatedMii, updatedMii.user.name!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); + } } return rateLimit.sendResponse({ success: true }); diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 824d51f..29e657b 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -8,7 +8,7 @@ import sharp from "sharp"; import qrcode from "qrcode-generator"; import { profanity } from "@2toad/profanity"; -import { MiiGender } from "@prisma/client"; +import { MiiGender, MiiPlatform } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -22,32 +22,57 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); -const submitSchema = z.object({ - name: nameSchema, - tags: tagsSchema, - description: z.string().trim().max(256).optional(), - qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { - error: "QR code size is not a valid Tomodachi Life QR code", - }), - 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(), -}); +const submitSchema = z + .object({ + platform: z.enum(MiiPlatform).default("THREE_DS"), + name: nameSchema, + tags: tagsSchema, + description: z.string().trim().max(256).optional(), + + // Switch + gender: z.enum(MiiGender).default("MALE"), + miiPortraitImage: z.union([z.instanceof(File), z.any()]).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", + }), + + // 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 and miiPortraitImage must be present + if (data.platform === "SWITCH") { + return data.gender !== undefined && data.miiPortraitImage !== undefined; + } + return true; + }, + { + message: "Gender and Mii portrait image are required for Switch platform", + path: ["gender", "miiPortraitImage"], + }, + ); export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); Sentry.setUser({ id: session.user.id, username: session.user.username }); - const rateLimit = new RateLimit(request, 2); + const rateLimit = new RateLimit(request, 3); const check = await rateLimit.handle(); if (check) return check; + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const { value } = await response.json(); - if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409); + if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503); - // Parse data + // Parse tags and QR code as JSON const formData = await request.formData(); let rawTags: string[]; @@ -62,18 +87,36 @@ export async function POST(request: NextRequest) { return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400); } + // Parse and check all submission info const parsed = submitSchema.safeParse({ + platform: formData.get("platform"), name: formData.get("name"), tags: rawTags, description: formData.get("description"), + + gender: formData.get("gender") ?? undefined, // ZOD MOMENT + miiPortraitImage: formData.get("miiPortraitImage"), + qrBytesRaw: rawQrBytesRaw, + image1: formData.get("image1"), image2: formData.get("image2"), image3: formData.get("image3"), }); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); - const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data; + const { + platform, + name: uncensoredName, + tags: uncensoredTags, + description: uncensoredDescription, + qrBytesRaw, + gender, + miiPortraitImage, + image1, + image2, + image3, + } = parsed.data; // Censor potential inappropriate words const name = profanity.censor(uncensoredName); @@ -81,43 +124,57 @@ export async function POST(request: NextRequest) { const description = uncensoredDescription && profanity.censor(uncensoredDescription); // Validate image files - const images: File[] = []; + const customImages: File[] = []; for (const img of [image1, image2, image3]) { if (!img) continue; const imageValidation = await validateImage(img); if (imageValidation.valid) { - images.push(img); + customImages.push(img); } else { return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); } } + // Check Mii portrait image as well (Switch) + if (platform === "SWITCH") { + const imageValidation = await validateImage(miiPortraitImage); + if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); + } + const qrBytes = new Uint8Array(qrBytesRaw); - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - Sentry.captureException(error, { extra: { stage: "qr-conversion" } }); - return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + // Convert QR code to JS (3DS) + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined; + if (platform === "THREE_DS") { + try { + conversion = convertQrCode(qrBytes); + } catch (error) { + Sentry.captureException(error, { extra: { stage: "qr-conversion" } }); + return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } // Create Mii in database const miiRecord = await prisma.mii.create({ data: { userId: Number(session.user.id), + platform, name, tags, description, + gender: gender ?? "MALE", - firstName: conversion.tomodachiLifeMii.firstName, - lastName: conversion.tomodachiLifeMii.lastName, - gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, - islandName: conversion.tomodachiLifeMii.islandName, - allowedCopying: conversion.mii.allowCopying, + // Automatically detect certain information if on 3DS + ...(platform === "THREE_DS" && + conversion && { + firstName: conversion.tomodachiLifeMii.firstName, + lastName: conversion.tomodachiLifeMii.lastName, + gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, + islandName: conversion.tomodachiLifeMii.islandName, + allowedCopying: conversion.mii.allowCopying, + }), }, }); @@ -125,34 +182,38 @@ export async function POST(request: NextRequest) { const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); await fs.mkdir(miiUploadsDirectory, { recursive: true }); - // Download the image of the Mii - let studioBuffer: Buffer; try { - const studioUrl = conversion.mii.studioUrl({ width: 512 }); - const studioResponse = await fetch(studioUrl); + let portraitBuffer: Buffer | undefined; - if (!studioResponse.ok) { - throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + // Download the image of the Mii (3DS) + if (platform === "THREE_DS") { + const studioUrl = conversion?.mii.studioUrl({ width: 512 }); + const studioResponse = await fetch(studioUrl!); + + if (!studioResponse.ok) { + throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + } + + portraitBuffer = Buffer.from(await studioResponse.arrayBuffer()); + } else if (platform === "SWITCH") { + portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer()); } - const studioArrayBuffer = await studioResponse.arrayBuffer(); - studioBuffer = Buffer.from(studioArrayBuffer); + if (!portraitBuffer) throw Error("Mii portrait buffer not initialised"); + const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer(); + const fileLocation = path.join(miiUploadsDirectory, "mii.webp"); + + await fs.writeFile(fileLocation, webpBuffer); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Failed to download Mii image:", error); + console.error("Failed to download/store Mii portrait:", error); Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } }); - return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); + return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); } try { - // Compress and store - const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); - const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); - - await fs.writeFile(studioFileLocation, studioWebpBuffer); - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); @@ -169,7 +230,6 @@ export async function POST(request: NextRequest) { const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); await fs.writeFile(codeFileLocation, codeWebpBuffer); - await generateMetadataImage(miiRecord, session.user.name!); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); @@ -182,7 +242,7 @@ export async function POST(request: NextRequest) { // Compress and store user images try { await Promise.all( - images.map(async (image, index) => { + customImages.map(async (image, index) => { const buffer = Buffer.from(await image.arrayBuffer()); const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); @@ -197,7 +257,7 @@ export async function POST(request: NextRequest) { id: miiRecord.id, }, data: { - imageCount: images.length, + imageCount: customImages.length, }, }); } catch (error) { diff --git a/src/app/globals.css b/src/app/globals.css index 6131603..45971bd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -91,6 +91,23 @@ body { @apply opacity-100 scale-100; } +/* Fallback Tooltips */ +[data-tooltip-span] { + @apply relative; +} + +[data-tooltip-span] > .tooltip { + @apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999; +} + +[data-tooltip-span] > .tooltip::before { + @apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400; +} + +[data-tooltip-span]:hover > .tooltip { + @apply opacity-100 scale-100; +} + /* Scrollbar */ /* Firefox */ * { diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 16a574e..ca784c4 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -12,9 +12,10 @@ import LikeButton from "@/components/like-button"; import ImageViewer from "@/components/image-viewer"; import DeleteMiiButton from "@/components/delete-mii"; import ShareMiiButton from "@/components/share-mii-button"; -import ScanTutorialButton from "@/components/tutorial/scan"; -import ProfilePicture from "@/components/profile-picture"; +import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan"; +import SwitchScanTutorialButton from "@/components/tutorial/switch-scan"; import Description from "@/components/description"; +import { MiiPlatform } from "@prisma/client"; interface Props { params: Promise<{ id: string }>; @@ -30,6 +31,7 @@ export async function generateMetadata({ params }: Props): Promise { include: { user: { select: { + name: true, username: true, }, }, @@ -44,28 +46,36 @@ export async function generateMetadata({ params }: Props): Promise { const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`; - const username = `@${mii.user.username}`; - return { metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags], - creator: username, + creator: mii.user.username, openGraph: { type: "article", title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, + images: [ + { + url: metadataImageUrl, + alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, + }, + ], publishedTime: mii.createdAt.toISOString(), - authors: username, + authors: mii.user.username, }, twitter: { card: "summary_large_image", title: `${mii.name} - TomodachiShare`, - description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], - creator: username, + description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, + images: [ + { + url: metadataImageUrl, + alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, + }, + ], + creator: mii.user.username!, }, alternates: { canonical: `/mii/${mii.id}`, @@ -116,8 +126,8 @@ export default async function MiiPage({ params }: Props) { @@ -134,25 +144,72 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Info */} -
    -
  • - Name:{" "} - - {mii.firstName} {mii.lastName} - -
  • -
  • - From: {mii.islandName} Island -
  • - {mii.allowedCopying !== null && ( + {mii.platform === "THREE_DS" && ( +
    • - Allowed Copying: + Name:{" "} + + {mii.firstName} {mii.lastName} +
    • - )} -
    +
  • + From: {mii.islandName} Island +
  • +
  • + Allowed Copying: +
  • +
+ )} + + {/* Mii Platform */} +
+
+ Platform +
+
+ +
+
+ {mii.platform === "THREE_DS" ? "3DS" : "Switch"} +
+ +
+ +
+ +
+ +
+
{/* Mii Gender */} -
+
+
+ Gender +
+
+ +
+
+ {mii.gender === "MALE" ? "Male" : "Female"} +
+
Report - + {mii.platform === "THREE_DS" ? : }
diff --git a/src/app/off-the-island/page.tsx b/src/app/off-the-island/page.tsx index 3256eb4..d44f77a 100644 --- a/src/app/off-the-island/page.tsx +++ b/src/app/off-the-island/page.tsx @@ -95,9 +95,7 @@ export default async function ExiledPage() {
mii image
-

- {mii.mii.name} -

+

{mii.mii.name}

Reason: {mii.reason}

diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index d8c379c..8bbd5be 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -12,7 +12,7 @@ interface Props { } export default function Carousel({ images, className }: Props) { - const [emblaRef, emblaApi] = useEmblaCarousel(); + const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 }); const [selectedIndex, setSelectedIndex] = useState(0); const [scrollSnaps, setScrollSnaps] = useState([]); const [isFocused, setIsFocused] = useState(false); diff --git a/src/components/mii-list/gender-select.tsx b/src/components/mii-list/gender-select.tsx index 2a9c0d3..147c645 100644 --- a/src/components/mii-list/gender-select.tsx +++ b/src/components/mii-list/gender-select.tsx @@ -10,7 +10,9 @@ export default function GenderSelect() { const searchParams = useSearchParams(); const [, startTransition] = useTransition(); - const [selected, setSelected] = useState((searchParams.get("gender") as MiiGender) ?? null); + const [selected, setSelected] = useState( + (searchParams.get("gender") as MiiGender) ?? null + ); const handleClick = (gender: MiiGender) => { const filter = selected === gender ? null : gender; @@ -31,24 +33,36 @@ export default function GenderSelect() { }; return ( -
+
diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 1e7f20e..3e3edba 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii-list/index.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { Prisma } from "@prisma/client"; +import { MiiGender, MiiPlatform, Prisma } from "@prisma/client"; import { Icon } from "@iconify/react"; import crypto from "crypto"; @@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro const parsed = searchSchema.safeParse(searchParams); if (!parsed.success) return

{parsed.error.issues[0].message}

; - const { q: query, sort, tags, exclude, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; + const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; // My Likes page let miiIdsLiked: number[] | undefined = undefined; @@ -52,6 +52,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro // Tag filtering ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), + // Platform + ...(platform && { platform: { equals: platform } }), // Gender ...(gender && { gender: { equals: gender } }), // Allow Copying @@ -71,6 +73,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro }, }, }), + platform: true, name: true, imageCount: true, tags: true, @@ -143,7 +146,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro [totalCount, filteredCount, list] = await Promise.all([ prisma.mii.count({ where: { ...where, userId } }), prisma.mii.count({ where, skip, take: limit }), - prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), + prisma.mii.findMany({ + where, + orderBy, + select, + skip: (page - 1) * limit, + take: limit, + }), ]); } @@ -156,7 +165,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro return (
-
+
{totalCount == filteredCount ? ( <> diff --git a/src/components/mii-list/platform-select.tsx b/src/components/mii-list/platform-select.tsx new file mode 100644 index 0000000..8915d8e --- /dev/null +++ b/src/components/mii-list/platform-select.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Icon } from "@iconify/react"; +import { MiiPlatform } from "@prisma/client"; + +export default function PlatformSelect() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const [selected, setSelected] = useState((searchParams.get("platform") as MiiPlatform) ?? null); + + const handleClick = (platform: MiiPlatform) => { + const filter = selected === platform ? null : platform; + setSelected(filter); + + const params = new URLSearchParams(searchParams); + if (filter) { + params.set("platform", filter); + } else { + params.delete("platform"); + } + + startTransition(() => { + router.push(`?${params.toString()}`); + }); + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/submit-form/index.tsx b/src/components/submit-form/index.tsx index 7426689..2b46938 100644 --- a/src/components/submit-form/index.tsx +++ b/src/components/submit-form/index.tsx @@ -7,6 +7,7 @@ import { FileWithPath } from "react-dropzone"; import { Icon } from "@iconify/react"; import qrcode from "qrcode-generator"; +import { MiiGender, MiiPlatform } from "@prisma/client"; import { nameSchema, tagsSchema } from "@/lib/schemas"; import { convertQrCode } from "@/lib/qr-codes"; @@ -15,9 +16,11 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; import TagSelector from "../tag-selector"; import ImageList from "./image-list"; +import PortraitUpload from "./portrait-upload"; import QrUpload from "./qr-upload"; import QrScanner from "./qr-scanner"; -import SubmitTutorialButton from "../tutorial/submit"; +import SwitchSubmitTutorialButton from "../tutorial/switch-submit"; +import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit"; import LikeButton from "../like-button"; import Carousel from "../carousel"; import SubmitButton from "../submit-button"; @@ -35,16 +38,19 @@ export default function SubmitForm() { ); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); - const [studioUrl, setStudioUrl] = useState(); - const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState(); - - const [error, setError] = useState(undefined); + const [miiPortraitUri, setMiiPortraitUri] = useState(); + const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState(); const [name, setName] = useState(""); const [tags, setTags] = useState([]); const [description, setDescription] = useState(""); const [qrBytesRaw, setQrBytesRaw] = useState([]); + const [platform, setPlatform] = useState("SWITCH"); + const [gender, setGender] = useState("MALE"); + + const [error, setError] = useState(undefined); + const handleSubmit = async () => { // Validate before sending request const nameValidation = nameSchema.safeParse(name); @@ -60,6 +66,7 @@ export default function SubmitForm() { // Send request to server const formData = new FormData(); + formData.append("platform", platform); formData.append("name", name); formData.append("tags", JSON.stringify(tags)); formData.append("description", description); @@ -69,6 +76,24 @@ export default function SubmitForm() { formData.append(`image${index + 1}`, file); }); + if (platform === "SWITCH") { + const response = await fetch(miiPortraitUri!); + + if (!response.ok) { + setError("Failed to check Mii portrait. Did you upload one?"); + return; + } + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + setError("Invalid image file returned"); + return; + } + + formData.append("gender", gender); + formData.append("miiPortraitImage", blob); + } + const response = await fetch("/api/submit", { method: "POST", body: formData, @@ -96,38 +121,39 @@ export default function SubmitForm() { return; } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - return; + // Convert QR code to JS (3DS) + if (platform === "THREE_DS") { + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; + try { + conversion = convertQrCode(qrBytes); + setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 })); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + return; + } } + // Generate a new QR code for aesthetic reasons try { - setStudioUrl(conversion.mii.studioUrl({ width: 512 })); - - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); generatedCode.addData(byteString, "Byte"); generatedCode.make(); - setGeneratedQrCodeUrl(generatedCode.createDataURL()); + setGeneratedQrCodeUri(generatedCode.createDataURL()); } catch { - setError("Failed to get and/or generate Mii images"); + setError("Failed to regenerate QR code"); } }; preview(); - }, [qrBytesRaw]); + }, [qrBytesRaw, platform]); return (
- URL.createObjectURL(file))]} /> + URL.createObjectURL(file))]} />

@@ -162,6 +188,47 @@ export default function SubmitForm() {

+ {/* Platform select */} +
+ +
+ {/* Animated indicator */} + {/* TODO: maybe change width as part of animation? */} +
+ + {/* Switch button */} + + + {/* 3DS button */} + +
+
+ + {/* Name */}
+ {/* Description */}
-