Compare commits

...

7 commits

Author SHA1 Message Date
76fecca011 fix: remove island name from metadata in mii page
only automatically works for 3DS and I don't want to ask people for an
island name if on switch
2025-09-15 22:20:30 +01:00
f9dd7a396c style: fix responsiveness of filter menu 2025-09-14 17:17:41 +01:00
93e26b8937 fix: 'metadata' type images stretching mii portrait for switch miis 2025-09-14 17:11:59 +01:00
43c67d75a9 feat: platform filter, filtering redesign, show platform on mii pages 2025-09-14 15:27:13 +01:00
90a6b741be feat: groundwork for different platform tutorials 2025-09-14 12:36:11 +01:00
e1b269d99b Merge branch 'main' into feat/living-the-dream-qr-code 2025-09-14 12:25:52 +01:00
20f1c51f0c feat: groundwork for 'living the dream' mii submissions
Based on the screenshots from yesterday's Nintendo Direct, it is
presumed that the Mii editor in "Living the Dream" is similar to
Miitopia's one.

This commit lays the groundwork for Miis created in the sequel game.
However, due to the way TomodachiShare generates portraits of the Miis,
I can't do that unless there is a way to parse the QR code data and
render the Mii.

Note: I don't know if Nintendo will use access codes (as was the case
with Miitopia) therefore, as a precaution, another branch will be
created in anticipation for that.
2025-09-13 15:03:12 +01:00
40 changed files with 985 additions and 294 deletions

View file

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

View file

@ -68,18 +68,20 @@ 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
allowedCopying Boolean
islandName String?
allowedCopying Boolean?
createdAt DateTime @default(now())
@ -153,6 +155,11 @@ model Punishment {
@@map("punishments")
}
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender {
MALE
FEMALE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View file

@ -136,7 +136,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(mii, mii.user.username!);
try {
await generateMetadataImage(mii, mii.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
}
return rateLimit.sendResponse({ success: true });

View file

@ -7,7 +7,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";
@ -21,29 +21,52 @@ 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(),
})
.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 });
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 { 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[];
@ -52,64 +75,85 @@ export async function POST(request: NextRequest) {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch {
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400);
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 data = parsed.data;
// Censor potential inappropriate words
const name = profanity.censor(uncensoredName);
const tags = uncensoredTags.map((t) => profanity.censor(t));
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
const name = profanity.censor(data.name);
const tags = data.tags.map((t) => profanity.censor(t));
const description = data.description && profanity.censor(data.description);
// Validate image files
const images: File[] = [];
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
for (const img of [data.image1, data.image2, data.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);
}
}
const qrBytes = new Uint8Array(qrBytesRaw);
// Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") {
const imageValidation = await validateImage(data.miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
// Convert QR code to JS
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
const qrBytes = new Uint8Array(data.qrBytesRaw);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
if (data.platform === "THREE_DS") {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
}
}
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
userId: Number(session.user.id),
platform: data.platform,
name,
tags,
description,
gender: data.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
...(data.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,
}),
},
});
@ -117,33 +161,37 @@ 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 (data.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 (data.platform === "SWITCH") {
portraitBuffer = Buffer.from(await data.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);
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
console.error("Failed to download/store Mii portrait:", error);
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");
@ -160,19 +208,25 @@ export async function POST(request: NextRequest) {
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
try {
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
}
// 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`);
@ -187,7 +241,7 @@ export async function POST(request: NextRequest) {
id: miiRecord.id,
},
data: {
imageCount: images.length,
imageCount: customImages.length,
},
});
} catch (error) {

View file

@ -64,6 +64,7 @@ body {
@apply block;
}
/* Tooltips */
[data-tooltip] {
@apply relative z-10;
}
@ -81,7 +82,24 @@ body {
@apply opacity-100 scale-100;
}
/* Scrollbar */
/* 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;
}
/* Scrollbars */
/* Firefox */
* {
scrollbar-color: #ff8903 transparent;

View file

@ -66,13 +66,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.username!);
if (error) {
return rateLimit.sendResponse({ error }, status);
try {
buffer = await generateMetadataImage(mii, mii.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}

View file

@ -12,7 +12,8 @@ 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 ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
interface Props {
params: Promise<{ id: string }>;
@ -47,13 +48,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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 Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: 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.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
publishedTime: mii.createdAt.toISOString(),
authors: username,
@ -61,7 +62,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
creator: username,
},
@ -109,13 +110,13 @@ export default async function MiiPage({ params }: Props) {
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */}
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50">
<ImageViewer
src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={200}
height={200}
className="drop-shadow-lg hover:scale-105 transition-transform"
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
/>
</div>
{/* QR Code */}
@ -131,23 +132,76 @@ export default async function MiiPage({ params }: Props) {
<hr className="w-full border-t-2 border-t-amber-400" />
{/* Mii Info */}
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<li>
Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li>
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox !cursor-auto" />
</li>
</ul>
{mii.platform === "THREE_DS" && (
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<li>
Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li>
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" />
</li>
</ul>
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div
className={`tooltip !mt-1 ${
mii.platform === "THREE_DS"
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
}`}
>
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</div>
</div>
{/* Mii Gender */}
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="flex-grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
<div
className={`tooltip !mt-1 ${
mii.gender === "MALE"
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
}`}
>
{mii.gender === "MALE" ? "Male" : "Female"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
@ -231,7 +285,7 @@ export default async function MiiPage({ params }: Props) {
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</Link>
<ScanTutorialButton />
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
</div>
</div>
</div>

View file

@ -95,9 +95,7 @@ export default async function ExiledPage() {
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1" title={"hello"}>
{mii.mii.name}
</p>
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>

View file

@ -0,0 +1,95 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import { MiiGender, MiiPlatform } from "@prisma/client";
import TagFilter from "./tag-filter";
import PlatformSelect from "./platform-select";
import GenderSelect from "./gender-select";
export default function FilterMenu() {
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const rawTags = searchParams.get("tags") || "";
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const tags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
);
const [filterCount, setFilterCount] = useState(tags.length);
// Filter menu button handler
const handleClick = () => {
if (!isOpen) {
setIsOpen(true);
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
} else {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 200);
}
};
// Count all active filters
useEffect(() => {
let count = tags.length;
if (platform) count++;
if (gender) count++;
setFilterCount(count);
}, [tags, platform, gender]);
return (
<div className="relative">
<button className="pill button gap-2" onClick={handleClick}>
<Icon icon="mdi:filter" className="text-xl" />
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
</button>
{isOpen && (
<div
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`}
>
{/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
</div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
</div>
<GenderSelect />
</div>
)}
</div>
);
}

View file

@ -29,24 +29,28 @@ export default function GenderSelect() {
};
return (
<div className="grid grid-cols-2 gap-0.5">
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-blue-400 !border-blue-400 before:!border-b-blue-400">Male</div>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-pink-400 !border-pink-400 before:!border-b-pink-400">Female</div>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { MiiGender, Prisma } from "@prisma/client";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import { z } from "zod";
@ -10,8 +10,7 @@ import { querySchema } from "@/lib/schemas";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import GenderSelect from "./gender-select";
import TagFilter from "./tag-filter";
import FilterMenu from "./filter-menu";
import SortSelect from "./sort-select";
import Carousel from "../carousel";
import LikeButton from "../like-button";
@ -36,6 +35,7 @@ const searchSchema = z.object({
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
@ -60,7 +60,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -82,6 +82,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Profiles
@ -99,6 +101,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
@ -184,7 +187,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-[56rem]:flex-col">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
{totalCount == filteredCount ? (
<>
@ -201,9 +204,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
)}
</div>
<div className="flex items-center justify-end gap-2 w-full min-[56rem]:max-w-2/3 max-[56rem]:justify-center max-sm:flex-col">
<GenderSelect />
<TagFilter />
<div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
</div>

View file

@ -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<MiiPlatform | null>((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 (
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("THREE_DS")}
aria-label="Filter for 3DS Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-sky-400 !border-sky-400 before:!border-b-sky-400">3DS</div>
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")}
aria-label="Filter for Switch Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-red-400 !border-red-400 before:!border-b-red-400">Switch</div>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</button>
</div>
);
}

View file

@ -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,15 +16,28 @@ 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";
import Dropzone from "../dropzone";
export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback(
@ -34,17 +48,6 @@ export default function SubmitForm() {
[files.length]
);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
@ -60,6 +63,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 +73,14 @@ export default function SubmitForm() {
formData.append(`image${index + 1}`, file);
});
if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!);
const blob = await response.blob();
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
}
const response = await fetch("/api/submit", {
method: "POST",
body: formData,
@ -96,38 +108,41 @@ 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 (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel images={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
<Carousel
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -162,6 +177,46 @@ export default function SubmitForm() {
<hr className="flex-grow border-zinc-300" />
</div>
{/* Platform select */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Platform
</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">
{/* Animated indicator */}
<div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
}`}
></div>
{/* Switch button */}
<button
type="button"
onClick={() => setPlatform("SWITCH")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch
</button>
{/* 3DS button */}
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS
</button>
</div>
</div>
{/* Name */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Name
@ -185,11 +240,13 @@ export default function SubmitForm() {
<TagSelector tags={tags} setTags={setTags} />
</div>
{/* Description */}
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="reason-note" className="font-semibold py-2">
<label htmlFor="description" className="font-semibold py-2">
Description
</label>
<textarea
name="description"
rows={3}
maxLength={256}
placeholder="(optional) Type a description..."
@ -199,7 +256,54 @@ export default function SubmitForm() {
/>
</div>
{/* Separator */}
{/* Gender (switch only) */}
{platform === "SWITCH" && (
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
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" />
</button>
<button
type="button"
onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
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" />
</button>
</div>
</div>
)}
{platform === "SWITCH" && (
<>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<PortraitUpload setImage={setMiiPortraitUri} />
</div>
</>
)}
{/* QR code selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>QR Code</span>
@ -209,19 +313,20 @@ export default function SubmitForm() {
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
{platform === "THREE_DS" ? (
<>
<ThreeDsSubmitTutorialButton />
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<SubmitTutorialButton />
<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>
</>
) : (
<SwitchSubmitTutorialButton />
)}
</div>
{/* Separator */}
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Custom images</span>

View file

@ -0,0 +1,36 @@
"use client";
import { useCallback } from "react";
import { FileWithPath } from "react-dropzone";
import Dropzone from "../dropzone";
interface Props {
setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
}
export default function PortraitUpload({ setImage }: Props) {
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0];
// Convert to Data URI
const reader = new FileReader();
reader.onload = async (event) => {
setImage(event.target!.result as string);
};
reader.readAsDataURL(file);
},
[setImage]
);
return (
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
Drag and drop your Mii&apos;s portrait here
<br />
or click to open
</p>
</Dropzone>
</div>
);
}

View file

@ -9,12 +9,11 @@ import QrFinder from "./qr-finder";
import { useSelect } from "downshift";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
export default function QrScanner({ setQrBytesRaw }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
@ -127,105 +126,112 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
};
}, [isOpen, permissionGranted, scanQRCode]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<>
<button type="button" aria-label="Use your camera" onClick={() => setIsOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
{isOpen && (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -14,32 +14,32 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
acceptedFiles.forEach((file) => {
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const file = acceptedFiles[0];
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0, image.width, image.height);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, image.width, image.height);
const code = jsQR(imageData.data, image.width, image.height);
if (!code) return;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0, image.width, image.height);
setQrBytesRaw(code.binaryData!);
};
image.src = event.target!.result as string;
const imageData = ctx.getImageData(0, 0, image.width, image.height);
const code = jsQR(imageData.data, image.width, image.height);
if (!code) return;
setQrBytesRaw(code.binaryData!);
};
reader.readAsDataURL(file);
});
image.src = event.target!.result as string;
};
reader.readAsDataURL(file);
},
[setQrBytesRaw]
);

View file

@ -7,7 +7,7 @@ import { Icon } from "@iconify/react";
import TutorialPage from "./page";
export default function ScanTutorialButton() {
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
@ -66,11 +66,11 @@ export default function ScanTutorialButton() {
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/adding-mii/step5.png" />
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/3ds/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/3ds/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/3ds/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/3ds/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/3ds/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div>
</div>

View file

@ -8,7 +8,7 @@ import { Icon } from "@iconify/react";
import TutorialPage from "./page";
import StartingPage from "./starting-page";
export default function SubmitTutorialButton() {
export default function ThreeDsSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
@ -69,7 +69,7 @@ export default function SubmitTutorialButton() {
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<StartingPage emblaApi={emblaApi} />
<StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />

View file

@ -3,9 +3,10 @@ import { UseEmblaCarouselType } from "embla-carousel-react";
interface Props {
emblaApi: UseEmblaCarouselType[1] | undefined;
isSwitch?: boolean;
}
export default function StartingPage({ emblaApi }: Props) {
export default function StartingPage({ emblaApi, isSwitch }: Props) {
const goToTutorial = (index: number) => {
if (!emblaApi) return;
@ -29,7 +30,7 @@ export default function StartingPage({ emblaApi }: Props) {
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={"/tutorial/allow-copying/thumbnail.png"}
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
alt="Allow Copying thumbnail"
width={128}
height={128}
@ -45,7 +46,7 @@ export default function StartingPage({ emblaApi }: Props) {
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={"/tutorial/create-qr-code/thumbnail.png"}
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
alt="Creating QR code thumbnail"
width={128}
height={128}

View file

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import TutorialPage from "./page";
export default function SwitchScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/switch/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/switch/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/switch/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div>
</div>
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import TutorialPage from "./page";
import StartingPage from "./starting-page";
export default function SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/switch/allow-copying/step2.png" />
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/switch/allow-copying/step3.png" />
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/switch/allow-copying/step4.png" />
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/switch/allow-copying/step5.png" />
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/switch/allow-copying/step6.png" />
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/switch/allow-copying/step7.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
<StartingPage emblaApi={emblaApi} />
{/* Create QR Code */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/create-qr-code/step2.png" />
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/switch/create-qr-code/step3.png" />
<TutorialPage
text="4. Select and press 'OK' on the Mii you wish to submit"
imageSrc="/tutorial/switch/create-qr-code/step4.png"
/>
<TutorialPage
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
imageSrc="/tutorial/switch/create-qr-code/step5.png"
/>
<TutorialPage
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)."
imageSrc="/tutorial/switch/create-qr-code/step6.png"
/>
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} />
</div>
</div>
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -1,5 +1,5 @@
// This file's extension is .tsx because I am using JSX for satori to generate images
// These are disabled because satori is not Next.JS and is turned into an image anyways
// This file's extension is .tsx because JSX is used for satori to generate images
// Warnings below are disabled since satori is not Next.JS and is turned into an image anyways
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
@ -14,9 +14,9 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = 128;
const MAX_IMAGE_DIMENSIONS = 1024;
const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB
const MIN_IMAGE_DIMENSIONS = [320, 240];
const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
//#region Image validation
@ -43,12 +43,12 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (
!metadata.width ||
!metadata.height ||
metadata.width < MIN_IMAGE_DIMENSIONS ||
metadata.width > MAX_IMAGE_DIMENSIONS ||
metadata.height < MIN_IMAGE_DIMENSIONS ||
metadata.height > MAX_IMAGE_DIMENSIONS
metadata.width < MIN_IMAGE_DIMENSIONS[0] ||
metadata.width > MAX_IMAGE_DIMENSIONS[0] ||
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
) {
return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" };
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 320x240 and 1920x1080" };
}
// Check for inappropriate content
@ -121,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
);
};
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> {
export async function generateMetadataImage(mii: Mii, author: string): Promise<Buffer> {
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently
@ -146,8 +146,14 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} />
<div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img
src={miiImage}
width={248}
height={248}
tw="w-full h-full"
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }}
/>
</div>
{/* QR code */}
@ -197,16 +203,11 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file
try {
// I tried using .webp here but the quality looked awful
// but it actually might be well-liked due to the hatred of .webp
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}
// I tried using .webp here but the quality looked awful
// but it actually might be well-liked due to the hatred of .webp
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
return { buffer };
return buffer;
}
//#endregion