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.
This commit is contained in:
parent
066c215ea4
commit
20f1c51f0c
13 changed files with 612 additions and 262 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,13 +109,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 +131,25 @@ 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 Gender */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className={`grid grid-cols-2 gap-2 ${mii.platform !== "THREE_DS" && "mt-4"}`}>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
36
src/components/submit-form/portrait-upload.tsx
Normal file
36
src/components/submit-form/portrait-upload.tsx
Normal 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's portrait here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
131
src/components/tutorial/3ds-submit.tsx
Normal file
131
src/components/tutorial/3ds-submit.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"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 ThreeDsSubmitTutorialButton() {
|
||||
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 emblaApi={emblaApi} />
|
||||
|
||||
{/* Allow Copying */}
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
|
||||
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/allow-copying/step2.png" />
|
||||
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/allow-copying/step3.png" />
|
||||
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/allow-copying/step4.png" />
|
||||
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/allow-copying/step5.png" />
|
||||
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/allow-copying/step6.png" />
|
||||
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/allow-copying/step7.png" />
|
||||
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
|
||||
|
||||
<StartingPage emblaApi={emblaApi} />
|
||||
|
||||
{/* Create QR Code */}
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
|
||||
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/create-qr-code/step2.png" />
|
||||
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/create-qr-code/step3.png" />
|
||||
<TutorialPage text="4. Select and press 'OK' on the Mii you wish to submit" imageSrc="/tutorial/create-qr-code/step4.png" />
|
||||
<TutorialPage
|
||||
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
|
||||
imageSrc="/tutorial/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/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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 SwitchSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -197,16 +197,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
|
||||
|
|
|
|||
Loading…
Reference in a new issue