Compare commits

..

10 commits

82 changed files with 1462 additions and 1685 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.next
node_modules
.git
uploads
Dockerfile
.dockerignore
.gitignore
README.md
DEVELOPMENT.md
LICENSE
.env*

View file

@ -4,31 +4,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
images: { images: {
localPatterns: [ unoptimized: true,
{
pathname: "/mii/*/image",
},
{
pathname: "/profile/*/picture",
},
{
pathname: "/tutorial/**",
},
{
pathname: "/guest.webp",
},
],
remotePatterns: [
{
hostname: "avatars.githubusercontent.com",
},
{
hostname: "cdn.discordapp.com",
},
{
hostname: "studio.mii.nintendo.com",
},
],
}, },
}; };

View file

@ -16,22 +16,22 @@
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@sentry/nextjs": "^10.40.0", "@sentry/nextjs": "^10.45.0",
"bit-buffer": "^0.3.0", "bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^21.3.0", "file-type": "^21.3.3",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "16.1.6", "next": "16.2.0",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"redis": "^5.11.0", "redis": "^5.11.0",
"satori": "^0.19.2", "satori": "^0.25.0",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
@ -39,20 +39,20 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.4", "@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.3.3", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^10.0.2", "eslint": "^10.0.3",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.2.0",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `username` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `usernameUpdatedAt` on the `users` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "users_username_key";
-- AlterTable
ALTER TABLE "miis" ALTER COLUMN "allowedCopying" DROP NOT NULL;
-- AlterTable
ALTER TABLE "users" DROP COLUMN "username",
DROP COLUMN "usernameUpdatedAt";

View file

@ -9,7 +9,6 @@ datasource db {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String? @unique
name String name String
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
@ -19,8 +18,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
usernameUpdatedAt DateTime? imageUpdatedAt DateTime?
imageUpdatedAt DateTime?
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]

BIN
public/guest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/tutorial/switch/step1.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
public/tutorial/switch/step2.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
public/tutorial/switch/step3.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
public/tutorial/switch/step4.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
public/tutorial/switch/step5.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View file

@ -21,7 +21,7 @@ export const metadata: Metadata = {
export default async function AdminPage() { export default async function AdminPage() {
const session = await auth(); const session = await auth();
if (!session || Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) redirect("/404"); if (!session || Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) redirect("/404");
return ( return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">

View file

@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await request.text(); const body = await request.text();
bannerText = body; bannerText = body;
@ -23,7 +23,7 @@ export async function DELETE() {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
bannerText = null; bannerText = null;
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });

View file

@ -12,7 +12,7 @@ export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await request.json(); const body = await request.json();
const validatedCanSubmit = z.boolean().safeParse(body); const validatedCanSubmit = z.boolean().safeParse(body);

View file

@ -8,7 +8,7 @@ export async function GET(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const parsed = idSchema.safeParse(searchParams.get("id")); const parsed = idSchema.safeParse(searchParams.get("id"));
@ -51,7 +51,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
name: user.name, name: user.name,
username: user.username,
image: user.image, image: user.image,
createdAt: user.createdAt, createdAt: user.createdAt,
punishments: user.punishments, punishments: user.punishments,

View file

@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const parsedUserId = idSchema.safeParse(searchParams.get("id")); const parsedUserId = idSchema.safeParse(searchParams.get("id"));
@ -69,7 +69,7 @@ export async function DELETE(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const parsedPunishmentId = idSchema.safeParse(searchParams.get("id")); const parsedPunishmentId = idSchema.safeParse(searchParams.get("id"));

View file

@ -7,7 +7,7 @@ export async function PATCH() {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Start processing in background // Start processing in background
regenerateImages().catch(console.error); regenerateImages().catch(console.error);

View file

@ -10,7 +10,7 @@ import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 3); const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -24,7 +24,7 @@ export async function PATCH(request: NextRequest) {
try { try {
await prisma.user.update({ await prisma.user.update({
where: { id: Number(session.user.id) }, where: { id: Number(session.user?.id) },
data: { description: profanity.censor(description) }, data: { description: profanity.censor(description) },
}); });
} catch (error) { } catch (error) {

View file

@ -8,7 +8,7 @@ import { RateLimit } from "@/lib/rate-limit";
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user.id, name: session.user.name });
const rateLimit = new RateLimit(request, 1); const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle(); const check = await rateLimit.handle();

View file

@ -1,40 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { displayNameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { displayName } = await request.json();
if (!displayName) return rateLimit.sendResponse({ error: "New display name is required" }, 400);
const validation = displayNameSchema.safeParse(displayName);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(displayName)) return rateLimit.sendResponse({ error: "Display name contains inappropriate words" }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { name: displayName },
});
} catch (error) {
console.error("Failed to update display name:", error);
Sentry.captureException(error, { extra: { stage: "update-display-name" } });
return rateLimit.sendResponse({ error: "Failed to update display name" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { userNameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, name: session.user.name });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { name } = await request.json();
if (!name) return rateLimit.sendResponse({ error: "New name is required" }, 400);
const validation = userNameSchema.safeParse(name);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(name)) return rateLimit.sendResponse({ error: "Name contains inappropriate words" }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { name },
});
} catch (error) {
console.error("Failed to update name:", error);
Sentry.captureException(error, { extra: { stage: "update-name" } });
return rateLimit.sendResponse({ error: "Failed to update name" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -21,14 +21,14 @@ const formDataSchema = z.object({
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 3); const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
// Check if profile picture was updated in the last 7 days // Check if profile picture was updated in the last 7 days
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id) } }); const user = await prisma.user.findUnique({ where: { id: Number(session.user?.id) } });
if (user && user.imageUpdatedAt) { if (user && user.imageUpdatedAt) {
const timePeriod = dayjs().subtract(7, "days"); const timePeriod = dayjs().subtract(7, "days");
const lastUpdate = dayjs(user.imageUpdatedAt); const lastUpdate = dayjs(user.imageUpdatedAt);
@ -48,8 +48,8 @@ export async function PATCH(request: NextRequest) {
// If there is no image, set the profile picture to the guest image // If there is no image, set the profile picture to the guest image
if (!image) { if (!image) {
await prisma.user.update({ await prisma.user.update({
where: { id: Number(session.user.id) }, where: { id: Number(session.user?.id) },
data: { image: `/guest.webp`, imageUpdatedAt: new Date() }, data: { image: `/guest.png`, imageUpdatedAt: new Date() },
}); });
return rateLimit.sendResponse({ success: true }); return rateLimit.sendResponse({ success: true });
@ -64,10 +64,10 @@ export async function PATCH(request: NextRequest) {
try { try {
const buffer = Buffer.from(await image.arrayBuffer()); const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer, { animated: true }).resize({ width: 128, height: 128 }).webp({ quality: 85 }).toBuffer(); const pngBuffer = await sharp(buffer, { animated: true }).resize({ width: 128, height: 128 }).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(uploadsDirectory, `${session.user.id}.webp`); const fileLocation = path.join(uploadsDirectory, `${session.user?.id}.png`);
await fs.writeFile(fileLocation, webpBuffer); await fs.writeFile(fileLocation, pngBuffer);
} catch (error) { } catch (error) {
console.error("Error uploading profile picture:", error); console.error("Error uploading profile picture:", error);
Sentry.captureException(error, { extra: { stage: "upload-profile-picture" } }); Sentry.captureException(error, { extra: { stage: "upload-profile-picture" } });
@ -76,8 +76,8 @@ export async function PATCH(request: NextRequest) {
try { try {
await prisma.user.update({ await prisma.user.update({
where: { id: Number(session.user.id) }, where: { id: Number(session.user?.id) },
data: { image: `/profile/${session.user.id}/picture`, imageUpdatedAt: new Date() }, data: { image: `/profile/${session.user?.id}/picture`, imageUpdatedAt: new Date() },
}); });
} catch (error) { } catch (error) {
console.error("Failed to update profile picture:", error); console.error("Failed to update profile picture:", error);

View file

@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import dayjs from "dayjs";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { usernameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { username } = await request.json();
if (!username) return rateLimit.sendResponse({ error: "New username is required" }, 400);
// Check if username was updated in the last 90 days
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id) } });
if (user && user.usernameUpdatedAt) {
const timePeriod = dayjs().subtract(90, "days");
const lastUpdate = dayjs(user.usernameUpdatedAt);
if (lastUpdate.isAfter(timePeriod)) return rateLimit.sendResponse({ error: "Username was changed in the last 90 days" }, 400);
}
const validation = usernameSchema.safeParse(username);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(username)) return rateLimit.sendResponse({ error: "Username contains inappropriate words" }, 400);
const existingUser = await prisma.user.findUnique({ where: { username } });
if (existingUser) return rateLimit.sendResponse({ error: "Username is already taken" }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { username, usernameUpdatedAt: new Date() },
});
} catch (error) {
console.error("Failed to update username:", error);
Sentry.captureException(error, { extra: { stage: "update-username" } });
return rateLimit.sendResponse({ error: "Failed to update username" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -14,7 +14,7 @@ const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 30, "/api/mii/delete"); const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -33,7 +33,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
}); });
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404); if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (!(Number(session.user.id) === mii.userId || Number(session.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403); return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString()); const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { z } from "zod"; import { z } from "zod";
import { Mii } from "@prisma/client"; import { Mii, Prisma } from "@prisma/client";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@ -29,7 +29,7 @@ const editSchema = z.object({
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -49,7 +49,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}); });
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404); if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (!(Number(session.user.id) === mii.userId || Number(session.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403); return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
// Parse form data // Parse form data
@ -90,7 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
// Edit Mii in database // Edit Mii in database
const updateData: Partial<Mii> = {}; const updateData: Prisma.MiiUpdateInput = {};
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potential inappropriate words if (name !== undefined) updateData.name = profanity.censor(name); // Censor potential inappropriate words
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t)); // Same here if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t)); // Same here
if (description !== undefined) updateData.description = profanity.censor(description); if (description !== undefined) updateData.description = profanity.censor(description);
@ -126,10 +126,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
await Promise.all( await Promise.all(
images.map(async (image, index) => { images.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer()); const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const pngBuffer = await sharp(buffer).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
await fs.writeFile(fileLocation, webpBuffer); await fs.writeFile(fileLocation, pngBuffer);
}), }),
); );
} catch (error) { } catch (error) {

View file

@ -22,7 +22,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const existingLike = await tx.like.findUnique({ const existingLike = await tx.like.findUnique({
where: { where: {
userId_miiId: { userId_miiId: {
userId: Number(session.user.id), userId: Number(session.user?.id),
miiId, miiId,
}, },
}, },
@ -33,7 +33,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
await tx.like.delete({ await tx.like.delete({
where: { where: {
userId_miiId: { userId_miiId: {
userId: Number(session.user.id), userId: Number(session.user?.id),
miiId, miiId,
}, },
}, },
@ -42,7 +42,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// Add a like if it doesn't exist // Add a like if it doesn't exist
await tx.like.create({ await tx.like.create({
data: { data: {
userId: Number(session.user.id), userId: Number(session.user?.id),
miiId, miiId,
}, },
}); });

View file

@ -19,7 +19,7 @@ const reportSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 2); const rateLimit = new RateLimit(request, 2);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
include: { include: {
user: { user: {
select: { select: {
username: true; name: true;
}; };
}; };
}; };
@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
include: { include: {
user: { user: {
select: { select: {
username: true, name: true,
}, },
}, },
}, },
@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
where: { where: {
targetId: id, targetId: id,
reportType: type.toUpperCase() as ReportType, reportType: type.toUpperCase() as ReportType,
authorId: Number(session.user.id), authorId: Number(session.user?.id),
}, },
}); });
@ -79,7 +79,7 @@ export async function POST(request: NextRequest) {
targetId: id, targetId: id,
reason: reason.toUpperCase() as ReportReason, reason: reason.toUpperCase() as ReportReason,
reasonNotes: notes, reasonNotes: notes,
authorId: Number(session.user.id), authorId: Number(session.user?.id),
creatorId: mii ? mii.userId : undefined, creatorId: mii ? mii.userId : undefined,
}, },
}); });
@ -92,11 +92,11 @@ export async function POST(request: NextRequest) {
// Send notification to ntfy // Send notification to ntfy
if (process.env.NTFY_URL) { if (process.env.NTFY_URL) {
// This is only shown if report type is MII // This is only shown if report type is MII
const miiCreatorMessage = mii ? `by @${mii.user.username} (ID: ${mii.userId})` : ""; const miiCreatorMessage = mii ? `by ${mii.user.name} (ID: ${mii.userId})` : "";
await fetch(process.env.NTFY_URL, { await fetch(process.env.NTFY_URL, {
method: "POST", method: "POST",
body: `Report by @${session.user.username} (ID: ${session.user.id}) on ${type.toUpperCase()} (ID: ${id}) ${miiCreatorMessage}`, body: `Report by ${session.user?.name} (ID: ${session.user?.id}) on ${type.toUpperCase()} (ID: ${id}) ${miiCreatorMessage}`,
headers: { headers: {
Title: "Report recieved - TomodachiShare", Title: "Report recieved - TomodachiShare",
Priority: "urgent", Priority: "urgent",

View file

@ -14,7 +14,7 @@ export async function DELETE(request: NextRequest) {
const activePunishment = await prisma.punishment.findFirst({ const activePunishment = await prisma.punishment.findFirst({
where: { where: {
userId: Number(session.user.id), userId: Number(session.user?.id),
returned: false, returned: false,
}, },
include: { include: {

View file

@ -66,7 +66,7 @@ const submitSchema = z
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username }); Sentry.setUser({ id: session.user?.id, name: session.user?.name });
const rateLimit = new RateLimit(request, 3); const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -94,17 +94,18 @@ export async function POST(request: NextRequest) {
// Minify instructions to save space and improve user experience // Minify instructions to save space and improve user experience
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined; let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
if (formData.get("platform") === "SWITCH") { if (formData.get("platform") === "SWITCH") {
const DEFAULT_ZERO_FIELDS = new Set(["height", "distance", "rotation", "size", "stretch"]);
function minify(object: Partial<SwitchMiiInstructions>): Partial<SwitchMiiInstructions> { function minify(object: Partial<SwitchMiiInstructions>): Partial<SwitchMiiInstructions> {
for (const key in object) { for (const key in object) {
const value = object[key as keyof SwitchMiiInstructions]; const value = object[key as keyof SwitchMiiInstructions];
if (!value) { if (!value || (DEFAULT_ZERO_FIELDS.has(key) && value === 0)) {
delete object[key as keyof SwitchMiiInstructions]; delete object[key as keyof SwitchMiiInstructions];
continue; continue;
} }
// Recurse into nested objects if (typeof value === "object" && !Array.isArray(value)) {
if (typeof value === "object") {
minify(value as Partial<SwitchMiiInstructions>); minify(value as Partial<SwitchMiiInstructions>);
if (Object.keys(value).length === 0) { if (Object.keys(value).length === 0) {
@ -205,7 +206,7 @@ export async function POST(request: NextRequest) {
// Create Mii in database // Create Mii in database
const miiRecord = await prisma.mii.create({ const miiRecord = await prisma.mii.create({
data: { data: {
userId: Number(session.user.id), userId: Number(session.user?.id),
platform, platform,
name, name,
tags, tags,
@ -249,10 +250,11 @@ export async function POST(request: NextRequest) {
} }
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised"); if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer(); const pngBuffer = await sharp(portraitBuffer).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.webp"); const fileLocation = path.join(miiUploadsDirectory, "mii.png");
await fs.writeFile(fileLocation, webpBuffer); await fs.writeFile(fileLocation, pngBuffer);
await generateMetadataImage(miiRecord, session.user?.name!);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
@ -276,10 +278,10 @@ export async function POST(request: NextRequest) {
const codeBuffer = Buffer.from(codeBase64, "base64"); const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store // Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); const codePngBuffer = await sharp(codeBuffer).png({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.png");
await fs.writeFile(codeFileLocation, codeWebpBuffer); await fs.writeFile(codeFileLocation, codePngBuffer);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
@ -290,23 +292,15 @@ export async function POST(request: NextRequest) {
} }
} }
try {
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
console.error(error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "metadata-image" } });
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
}
// Compress and store user images // Compress and store user images
try { try {
await Promise.all( await Promise.all(
customImages.map(async (image, index) => { customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer()); const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const pngBuffer = await sharp(buffer).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
await fs.writeFile(fileLocation, webpBuffer); await fs.writeFile(fileLocation, pngBuffer);
}), }),
); );

View file

@ -1,38 +0,0 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import UsernameForm from "@/components/username-form";
export const metadata: Metadata = {
title: "Create your Username - TomodachiShare",
description: "Pick a unique username to start using TomodachiShare",
robots: {
index: false,
follow: false,
},
};
export default async function CreateUsernamePage() {
const session = await auth();
// If the user is not logged in or already has a username, redirect
if (!session || session?.user.username) {
redirect("/");
}
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to the island!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6">
<hr className="grow border-zinc-300" />
<span>Please create a username</span>
<hr className="grow border-zinc-300" />
</div>
<UsernameForm />
</div>
</div>
);
}

View file

@ -44,7 +44,7 @@ export default async function MiiPage({ params }: Props) {
}); });
// Check ownership // Check ownership
if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404"); if (!mii || (Number(session?.user?.id) !== mii.userId && Number(session?.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
return <EditForm mii={mii} likes={mii._count.likedBy} />; return <EditForm mii={mii} likes={mii._count.likedBy} />;
} }

View file

@ -12,9 +12,7 @@ export default async function LoginPage() {
const session = await auth(); const session = await auth();
// If the user is already logged in, redirect // If the user is already logged in, redirect
if (session) { if (session) redirect("/");
redirect("/");
}
return ( return (
<div className="grow flex items-center justify-center"> <div className="grow flex items-center justify-center">

View file

@ -32,8 +32,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.issues[0].message }, 400); if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.issues[0].message }, 400);
const { type: imageType } = searchParamsParsed.data; const { type: imageType } = searchParamsParsed.data;
const fileExtension = imageType === "metadata" ? ".png" : ".webp"; const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.png`);
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}${fileExtension}`);
let buffer: Buffer | undefined; let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata' // Only find Mii if image type is 'metadata'
@ -109,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
} }
return rateLimit.sendResponse(buffer, 200, { return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/webp", "Content-Type": "image/png",
"X-Robots-Tag": "noindex, noimageindex, nofollow", "X-Robots-Tag": "noindex, noimageindex, nofollow",
"Cache-Control": "no-store", "Cache-Control": "no-store",
}); });

View file

@ -14,7 +14,7 @@ import ImageViewer from "@/components/image-viewer";
import DeleteMiiButton from "@/components/mii/delete-mii-button"; import DeleteMiiButton from "@/components/mii/delete-mii-button";
import ShareMiiButton from "@/components/mii/share-mii-button"; import ShareMiiButton from "@/components/mii/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan"; import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan"; import SwitchScanTutorialButton from "@/components/tutorial/switch-add-mii";
import Description from "@/components/description"; import Description from "@/components/description";
import MiiInstructions from "@/components/mii/instructions"; import MiiInstructions from "@/components/mii/instructions";
@ -35,7 +35,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
user: { user: {
select: { select: {
name: true, name: true,
username: true,
}, },
}, },
_count: { _count: {
@ -49,16 +48,18 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`; const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const name = `@${mii.user.name}`;
return { return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags], keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: mii.user.username, creator: name,
openGraph: { openGraph: {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
@ -66,19 +67,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}, },
], ],
publishedTime: mii.createdAt.toISOString(), publishedTime: mii.createdAt.toISOString(),
authors: mii.user.username, authors: name,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
}, },
], ],
creator: mii.user.username!, creator: mii.user.name!,
}, },
alternates: { alternates: {
canonical: `/mii/${mii.id}`, canonical: `/mii/${mii.id}`,
@ -98,7 +99,6 @@ export default async function MiiPage({ params }: Props) {
user: { user: {
select: { select: {
name: true, name: true,
username: true,
}, },
}, },
likedBy: session?.user likedBy: session?.user
@ -291,7 +291,7 @@ export default async function MiiPage({ params }: Props) {
{/* Buttons */} {/* Buttons */}
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs"> <div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
{session && (Number(session.user.id) === mii.userId || Number(session.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) && ( {session && (Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) && (
<> <>
<Link aria-label="Edit Mii" href={`/edit/${mii.id}`}> <Link aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" /> <Icon icon="mdi:pencil" />

View file

@ -39,9 +39,6 @@ export default async function Page({ searchParams }: Props) {
const session = await auth(); const session = await auth();
const { page, tags } = await searchParams; const { page, tags } = await searchParams;
if (session?.user && !session.user.username) {
redirect("/create-username");
}
if (session?.user) { if (session?.user) {
const activePunishment = await prisma.punishment.findFirst({ const activePunishment = await prisma.punishment.findFirst({
where: { where: {

View file

@ -32,8 +32,8 @@ export default function PrivacyPage() {
<p className="mb-2">The following types of information are stored when you use this website:</p> <p className="mb-2">The following types of information are stored when you use this website:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside">
<li> <li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture are <strong>Account Information:</strong> When you sign up or log in using Discord or Github, your name, e-mail, and profile picture are collected.
collected. Your authentication tokens may also be temporarily stored to maintain your login session. Your authentication tokens may also be temporarily stored to maintain your login session.
</li> </li>
<li> <li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images). <strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
@ -77,7 +77,7 @@ export default function PrivacyPage() {
</p> </p>
<ul className="list-disc list-inside ml-4"> <ul className="list-disc list-inside ml-4">
<li>Errors and performance data is collected.</li> <li>Errors and performance data is collected.</li>
<li>Only your user ID and username are sent, no other personally identifiable information is collected.</li> <li>Only your user ID and name are sent, no other personally identifiable information is collected.</li>
<li>You can use ad blockers or browser privacy features to opt out.</li> <li>You can use ad blockers or browser privacy features to opt out.</li>
</ul> </ul>
</section> </section>

View file

@ -39,24 +39,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${user.name} (@${user.username}) - TomodachiShare`, title: `${user.name} - TomodachiShare`,
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`, description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`,
keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"], keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"],
creator: user.username, creator: user.name,
openGraph: { openGraph: {
type: "profile", type: "profile",
title: `${user.name} (@${user.username}) - TomodachiShare`, title: `${user.name} - TomodachiShare`,
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`, description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`,
images: [user.image ?? "/guest.webp"], images: [user.image ?? "/guest.png"],
username: user.username, username: user.name,
firstName: user.name,
}, },
twitter: { twitter: {
card: "summary", card: "summary",
title: `${user.name} (@${user.username}) - TomodachiShare`, title: `${user.name} - TomodachiShare`,
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`, description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`,
images: [user.image ?? "/guest.webp"], images: [user.image ?? "/guest.png"],
creator: user.username!, creator: user.name,
}, },
alternates: { alternates: {
canonical: `/profile/${user.id}`, canonical: `/profile/${user.id}`,

View file

@ -16,7 +16,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const userId = parsed.data; const userId = parsed.data;
const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.webp`); const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.png`);
try { try {
const buffer = await fs.readFile(filePath); const buffer = await fs.readFile(filePath);

View file

@ -21,7 +21,7 @@ export default async function ProfileSettingsPage() {
if (!session) redirect("/login"); if (!session) redirect("/login");
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } }); const user = await prisma.user.findUnique({ where: { id: Number(session.user?.id!) }, select: { description: true } });
return ( return (
<div> <div>

View file

@ -5,18 +5,7 @@ export default function robots(): MetadataRoute.Robots {
rules: { rules: {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: [ disallow: ["/*?*page=", "/profile*?*tags=", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
"/*?*page=",
"/profile*?*tags=",
"/create-username",
"/edit/*",
"/profile/settings",
"/random",
"/submit",
"/report/mii/*",
"/report/user/*",
"/admin",
],
}, },
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`, sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
}; };

View file

@ -22,10 +22,9 @@ export default async function SubmitPage() {
const session = await auth(); const session = await auth();
if (!session) redirect("/login"); if (!session) redirect("/login");
if (!session.user.username) redirect("/create-username");
const activePunishment = await prisma.punishment.findFirst({ const activePunishment = await prisma.punishment.findFirst({
where: { where: {
userId: Number(session?.user.id), userId: Number(session?.user?.id),
returned: false, returned: false,
}, },
}); });
@ -39,6 +38,12 @@ export default async function SubmitPage() {
} catch (error) { } catch (error) {
return <p>An error occurred!</p>; return <p>An error occurred!</p>;
} }
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
value = await response.json();
} catch (error) {
return <p>An error occurred!</p>;
}
if (!value) if (!value)
return ( return (

View file

@ -14,7 +14,6 @@ import PunishmentDeletionDialog from "./punishment-deletion-dialog";
interface ApiResponse { interface ApiResponse {
success: boolean; success: boolean;
name: string; name: string;
username: string;
image: string; image: string;
createdAt: string; createdAt: string;
punishments: Prisma.PunishmentGetPayload<{ punishments: Prisma.PunishmentGetPayload<{
@ -115,7 +114,7 @@ export default function Punishments() {
<ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" /> <ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
<div className="p-2 flex flex-col"> <div className="p-2 flex flex-col">
<p className="text-xl font-bold">{user.name}</p> <p className="text-xl font-bold">{user.name}</p>
<p className="text-black/60 text-sm font-medium">@{user.username}</p> <p className="text-black/60 text-sm font-medium">@{user.name}</p>
<p className="text-sm mt-auto"> <p className="text-sm mt-auto">
<span className="font-medium">Created:</span>{" "} <span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleString("en-GB", { {new Date(user.createdAt).toLocaleString("en-GB", {

View file

@ -63,7 +63,7 @@ export default function Description({ text, className }: Props) {
href={`/profile/${id}`} href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs" className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
> >
<ProfilePicture src={linkedProfile.image || "/guest.webp"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" /> <ProfilePicture src={linkedProfile.image || "/guest.png"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
{linkedProfile.name} {linkedProfile.name}
</Link> </Link>
); );

View file

@ -22,7 +22,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
onDrop: handleDrop, onDrop: handleDrop,
maxFiles: 3, maxFiles: 3,
accept: { accept: {
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp", ".heic"], "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
}, },
...options, ...options,
}); });

View file

@ -7,7 +7,7 @@ export default function LoginButtons() {
return ( return (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<button <button
onClick={() => signIn("discord", { redirectTo: "/create-username" })} onClick={() => signIn("discord", { redirectTo: "/" })}
aria-label="Login with Discord" aria-label="Login with Discord"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!" className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
> >
@ -15,7 +15,7 @@ export default function LoginButtons() {
Login with Discord Login with Discord
</button> </button>
<button <button
onClick={() => signIn("github", { redirectTo: "/create-username" })} onClick={() => signIn("github", { redirectTo: "/" })}
aria-label="Login with GitHub" aria-label="Login with GitHub"
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white" className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
> >

View file

@ -16,16 +16,15 @@ export default function DatingPreferencesViewer({ data, onChecked }: Props) {
const genderEnum = gender.toUpperCase() as MiiGender; const genderEnum = gender.toUpperCase() as MiiGender;
return ( return (
<div className="flex gap-1.5"> <div key={gender} className="flex gap-1.5">
<input <input
key={gender}
type="checkbox" type="checkbox"
id={gender} id={gender}
className="checkbox" className="checkbox"
checked={data.includes(genderEnum)} checked={data.includes(genderEnum)}
onChange={(e) => { {...(typeof window !== "undefined" && onChecked
if (onChecked) onChecked(e, genderEnum); ? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) }
}} : { readOnly: true })}
/> />
<label htmlFor={gender} className="text-sm select-none"> <label htmlFor={gender} className="text-sm select-none">
{gender} {gender}

View file

@ -76,7 +76,7 @@ function TableCell({ label, children }: TableCellProps) {
} }
function Section({ name, instructions, children, isSubSection }: SectionProps) { function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object") return null; if (typeof instructions !== "object" || !instructions) return null;
const type = "type" in instructions ? instructions.type : undefined; const type = "type" in instructions ? instructions.type : undefined;
const color = "color" in instructions ? instructions.color : undefined; const color = "color" in instructions ? instructions.color : undefined;
@ -87,7 +87,7 @@ function Section({ name, instructions, children, isSubSection }: SectionProps) {
const stretch = "stretch" in instructions ? instructions.stretch : undefined; const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return ( return (
<div className={`p-3 ${isSubSection ? "mt-2" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}> <div className={`p-3 ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3> <h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
<table className="w-full"> <table className="w-full">
@ -149,50 +149,33 @@ export default function MiiInstructions({ instructions }: Props) {
<ColorPosition color={hair.subColor} /> <ColorPosition color={hair.subColor} />
</TableCell> </TableCell>
)} )}
{hair.subColor2 && (
<TableCell label="Sub Color (Back)">
<ColorPosition color={hair.subColor2} />
</TableCell>
)}
{hair.style && <TableCell label="Tying Style">{hair.style}</TableCell>}
{hair.isFlipped && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
</Section> </Section>
)} )}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>} {eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && ( {eyes && (
<Section name="Eyes" instructions={eyes}> <Section name="Eyes" instructions={eyes}>
{eyes.eyesType && ( <Section isSubSection name="Main" instructions={eyes.main} />
<TableCell label="Eyes Type"> <Section isSubSection name="Eyelashes Top" instructions={eyes.eyelashesTop} />
<GridPosition index={eyes.eyesType} /> <Section isSubSection name="Eyelashes Bottom" instructions={eyes.eyelashesBottom} />
</TableCell> <Section isSubSection name="Eyelid Top" instructions={eyes.eyelidTop} />
)} <Section isSubSection name="Eyelid Bottom" instructions={eyes.eyelidBottom} />
{eyes.eyelashesTop && ( <Section isSubSection name="Eyeliner" instructions={eyes.eyeliner} />
<TableCell label="Eyelashes Top Type"> <Section isSubSection name="Pupil" instructions={eyes.pupil} />
<GridPosition index={eyes.eyelashesTop} />
</TableCell>
)}
{eyes.eyelashesBottom && (
<TableCell label="Eyelashes Bottom Type">
<GridPosition index={eyes.eyelashesBottom} />
</TableCell>
)}
{eyes.eyelidTop && (
<TableCell label="Eyelid Top Type">
<GridPosition index={eyes.eyelidTop} />
</TableCell>
)}
{eyes.eyelidBottom && (
<TableCell label="Eyelid Bottom Type">
<GridPosition index={eyes.eyelidBottom} />
</TableCell>
)}
{eyes.eyeliner && (
<TableCell label="Eyeliner Type">
<GridPosition index={eyes.eyeliner} />
</TableCell>
)}
{eyes.pupil && (
<TableCell label="Pupil Type">
<GridPosition index={eyes.pupil} />
</TableCell>
)}
</Section> </Section>
)} )}
{nose && <Section name="Nose" instructions={nose}></Section>} {nose && <Section name="Nose" instructions={nose}></Section>}
{lips && <Section name="Lips" instructions={lips}></Section>} {lips && (
<Section name="Lips" instructions={lips}>
{lips.hasLipstick && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
</Section>
)}
{ears && <Section name="Ears" instructions={ears}></Section>} {ears && <Section name="Ears" instructions={ears}></Section>}
{glasses && ( {glasses && (
<Section name="Glasses" instructions={glasses}> <Section name="Glasses" instructions={glasses}>
@ -213,7 +196,9 @@ export default function MiiInstructions({ instructions }: Props) {
<Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} /> <Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} />
<Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} /> <Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} />
<Section isSubSection name="Beard" instructions={other.beard} /> <Section isSubSection name="Beard" instructions={other.beard} />
<Section isSubSection name="Moustache" instructions={other.moustache} /> <Section isSubSection name="Moustache" instructions={other.moustache}>
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section>
<Section isSubSection name="Goatee" instructions={other.goatee} /> <Section isSubSection name="Goatee" instructions={other.goatee} />
<Section isSubSection name="Mole" instructions={other.mole} /> <Section isSubSection name="Mole" instructions={other.mole} />
<Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} /> <Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} />
@ -242,24 +227,24 @@ export default function MiiInstructions({ instructions }: Props) {
</div> </div>
)} )}
{datingPreferences && ( {datingPreferences && (
<div className="pl-2"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="text-lg font-semibold mt-4">Dating Preferences</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
<div className="w-min"> <div className="w-min">
<DatingPreferencesViewer data={datingPreferences} /> <DatingPreferencesViewer data={datingPreferences} />
</div> </div>
</div> </div>
)} )}
{voice && ( {voice && (
<div className="pl-2"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Voice</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<div className="w-min"> <div className="w-min">
<VoiceViewer data={voice} /> <VoiceViewer data={voice} />
</div> </div>
</div> </div>
)} )}
{personality && ( {personality && (
<div className="pl-2"> <div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Personality</h4> <h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
<div className="w-min"> <div className="w-min">
<PersonalityViewer data={personality} /> <PersonalityViewer data={personality} />
</div> </div>

View file

@ -1,3 +1,4 @@
import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client"; import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
@ -25,7 +26,6 @@ interface Props {
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) { export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
const session = await auth(); const session = await auth();
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
@ -34,7 +34,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
if (inLikesPage && session?.user.id) { if (inLikesPage && session?.user?.id) {
const likedMiis = await prisma.like.findMany({ const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) }, where: { userId: Number(session.user.id) },
select: { miiId: true }, select: { miiId: true },
@ -69,7 +69,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
user: { user: {
select: { select: {
id: true, id: true,
username: true, name: true,
}, },
}, },
}), }),
@ -192,19 +192,26 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
{miis.map((mii) => ( {miis.map((mii) => (
<div <div
key={mii.id} key={mii.id}
className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600" className="flex flex-col relative bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
> >
<Carousel <Carousel
images={[ images={[
`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=mii`,
...(platform === "THREE_DS" ? `/mii/${mii.id}/image?type=qr-code` : ""), ...(platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`), ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]} ]}
/> />
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}> <Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1" title={mii.name}>
{mii.name} {mii.name}
<div className="absolute right-0 top-1/2 -translate-y-1/2 text-[1.25rem] opacity-25">
{mii.platform === "SWITCH" ? (
<Icon icon="cib:nintendo-switch" className="text-red-400" />
) : (
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
)}
</div>
</Link> </Link>
<div id="tags" className="flex flex-wrap gap-1"> <div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag) => ( {mii.tags.map((tag) => (
@ -219,11 +226,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
{!userId && ( {!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis"> <Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username} @{mii.user?.name}
</Link> </Link>
)} )}
{userId && Number(session?.user.id) == userId && ( {userId && Number(session?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400"> <div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit"> <Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" /> <Icon icon="mdi:pencil" />

View file

@ -15,7 +15,7 @@ interface Props {
export default async function ProfileInformation({ userId, page }: Props) { export default async function ProfileInformation({ userId, page }: Props) {
const session = await auth(); const session = await auth();
const id = userId ? userId : Number(session?.user.id); const id = userId ? userId : Number(session?.user?.id);
const user = await prisma.user.findUnique({ where: { id } }); const user = await prisma.user.findUnique({ where: { id } });
const likedMiis = await prisma.like.count({ where: { userId: id } }); const likedMiis = await prisma.like.count({ where: { userId: id } });
@ -23,14 +23,14 @@ export default async function ProfileInformation({ userId, page }: Props) {
const isAdmin = id === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID); const isAdmin = id === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID);
const isContributor = process.env.NEXT_PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(id.toString()); const isContributor = process.env.NEXT_PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(id.toString());
const isOwnProfile = Number(session?.user.id) === id; const isOwnProfile = Number(session?.user?.id) === id;
return ( return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll"> <div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */} {/* Profile picture */}
<Link href={`/profile/${user.id}`} className="size-28 aspect-square"> <Link href={`/profile/${user.id}`} className="size-28 aspect-square">
<ProfilePicture src={user.image ?? "/guest.webp"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" /> <ProfilePicture src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</Link> </Link>
{/* User information */} {/* User information */}
<div className="flex flex-col w-full relative py-3"> <div className="flex flex-col w-full relative py-3">
@ -47,7 +47,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
</div> </div>
)} )}
</div> </div>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">@{user?.username}</h2> <h2 className="text-black/60 text-sm font-semibold wrap-break-word">ID: {user?.id}</h2>
<div className="mt-3 text-sm flex gap-8"> <div className="mt-3 text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}> <h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>

View file

@ -7,15 +7,15 @@ export default async function ProfileOverview() {
return ( return (
<li title="Your profile"> <li title="Your profile">
<Link href={`/profile/${session?.user.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile"> <Link href={`/profile/${session?.user?.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
<Image <Image
src={session?.user?.image ?? "/guest.webp"} src={session?.user?.image ?? "/guest.png"}
alt="profile picture" alt="profile picture"
width={40} width={40}
height={40} height={40}
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400" className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
/> />
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.username ?? "unknown"}</span> <span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.name ?? "unknown"}</span>
</Link> </Link>
</li> </li>
); );

View file

@ -7,5 +7,5 @@ export default function ProfilePicture(props: Partial<ImageProps>) {
const { src, ...rest } = props; const { src, ...rest } = props;
const [imgSrc, setImgSrc] = useState(src); const [imgSrc, setImgSrc] = useState(src);
return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} onError={() => setImgSrc("/guest.webp")} />; return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.png"} alt={"profile picture"} onError={() => setImgSrc("/guest.png")} />;
} }

View file

@ -2,9 +2,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import dayjs from "dayjs";
import { displayNameSchema, usernameSchema } from "@/lib/schemas"; import { userNameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture"; import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
@ -19,14 +18,10 @@ export default function ProfileSettings({ currentDescription }: Props) {
const router = useRouter(); const router = useRouter();
const [description, setDescription] = useState(currentDescription); const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined); const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined); const [nameChangeError, setNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days");
const handleSubmitDescriptionChange = async (close: () => void) => { const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description); const parsed = z.string().trim().max(256).safeParse(description);
@ -51,45 +46,22 @@ export default function ProfileSettings({ currentDescription }: Props) {
router.refresh(); router.refresh();
}; };
const handleSubmitDisplayNameChange = async (close: () => void) => { const handleSubmitNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName); const parsed = userNameSchema.safeParse(name);
if (!parsed.success) { if (!parsed.success) {
setDisplayNameChangeError(parsed.error.issues[0].message); setNameChangeError(parsed.error.issues[0].message);
return; return;
} }
const response = await fetch("/api/auth/display-name", { const response = await fetch("/api/auth/name", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }), body: JSON.stringify({ name }),
}); });
if (!response.ok) { if (!response.ok) {
const { error } = await response.json(); const { error } = await response.json();
setDisplayNameChangeError(error); setNameChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitUsernameChange = async (close: () => void) => {
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) {
setUsernameChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/username", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
const { error } = await response.json();
setUsernameChangeError(error);
return; return;
} }
@ -101,7 +73,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
<div> <div>
<h2 className="text-2xl font-bold">Profile Settings</h2> <h2 className="text-2xl font-bold">Profile Settings</h2>
<p className="text-sm text-zinc-500">Update your account info, and username.</p> <p className="text-sm text-zinc-500">Update your profile picture, description, name, etc.</p>
</div> </div>
{/* Separator */} {/* Separator */}
@ -146,58 +118,21 @@ export default function ProfileSettings({ currentDescription }: Props) {
{/* Change Name */} {/* Change Name */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3"> <div className="col-span-3">
<label className="font-semibold">Change Display Name</label> <label className="font-semibold">Change Name</label>
<p className="text-sm text-zinc-500">This is a display name shown on your profile feel free to change it anytime</p> <p className="text-sm text-zinc-500">This is your name shown on your profile and miis feel free to change it anytime</p>
</div> </div>
<div className="flex justify-end gap-1 h-min col-span-2"> <div className="flex justify-end gap-1 h-min col-span-2">
<input type="text" className="pill input flex-1" placeholder="Type here..." value={displayName} onChange={(e) => setDisplayName(e.target.value)} /> <input type="text" className="pill input flex-1" placeholder="Type here..." value={name} onChange={(e) => setName(e.target.value)} />
<SubmitDialogButton <SubmitDialogButton
title="Confirm Display Name Change" title="Confirm Name Change"
description="Are you sure? This will only be visible on your profile. You can change it again later." description="Are you sure? You can change it again later."
error={displayNameChangeError} error={nameChangeError}
onSubmit={handleSubmitDisplayNameChange} onSubmit={handleSubmitNameChange}
> >
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1"> <div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
<p className="font-semibold">New display name:</p> <p className="font-semibold">New name:</p>
<p className="indent-4">&apos;{displayName}&apos;</p> <p className="indent-4">&apos;{name}&apos;</p>
</div>
</SubmitDialogButton>
</div>
</div>
{/* Change Username */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Change Username</label>
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
</div>
<div className="flex justify-end gap-1 col-span-2">
<div className="relative flex-1">
<input
type="text"
className="pill input w-full indent-4"
placeholder="Type here..."
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span>
</div>
<SubmitDialogButton
title="Confirm Username Change"
description="Are you sure? Your username is your unique indentifier and can only be changed every 90 days."
error={usernameChangeError}
onSubmit={handleSubmitUsernameChange}
>
<p className="text-sm text-zinc-500 mt-2">
After submitting, you can change it again on{" "}
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
</p>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
<p className="font-semibold">New username:</p>
<p className="indent-4">&apos;@{username}&apos;</p>
</div> </div>
</SubmitDialogButton> </SubmitDialogButton>
</div> </div>

View file

@ -59,7 +59,7 @@ export default function ProfilePictureSettings() {
</p> </p>
<Image <Image
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.webp"} src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
alt="new profile picture" alt="new profile picture"
width={96} width={96}
height={96} height={96}
@ -93,7 +93,7 @@ export default function ProfilePictureSettings() {
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center"> <div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
<p className="font-semibold mb-2">New profile picture:</p> <p className="font-semibold mb-2">New profile picture:</p>
<Image <Image
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.webp"} src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
alt="new profile picture" alt="new profile picture"
width={128} width={128}
height={128} height={128}

View file

@ -43,11 +43,8 @@ export default function ReportUserForm({ user }: Props) {
<hr className="border-zinc-300" /> <hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<ProfilePicture src={user.image ?? "/guest.webp"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" /> <ProfilePicture src={user.image ?? "/guest.png"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" />
<div className="flex flex-col justify-center"> <p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
<p className="text-sm font-bold overflow-hidden text-ellipsis">@{user.username}</p>
</div>
</div> </div>
<div className="w-full grid grid-cols-3 items-center"> <div className="w-full grid grid-cols-3 items-center">

View file

@ -90,7 +90,7 @@ export default function EditForm({ mii, likes }: Props) {
const response = await fetch(path); const response = await fetch(path);
const blob = await response.blob(); const blob = await response.blob();
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path }); return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
}), }),
); );

View file

@ -51,51 +51,46 @@ export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH"); const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE"); const [gender, setGender] = useState<MiiGender>("MALE");
const instructions = useRef<SwitchMiiInstructions>({ const instructions = useRef<SwitchMiiInstructions>({
head: { type: 0, skinColor: 0 }, head: { type: null, skinColor: null },
hair: { hair: {
setType: 0, setType: null,
bangsType: 0, bangsType: null,
backType: 0, backType: null,
color: 0, color: null,
subColor: 0, subColor: null,
style: 0, subColor2: null,
style: null,
isFlipped: false, isFlipped: false,
}, },
eyebrows: { type: 0, color: 0, height: 0, distance: 0, rotation: 0, size: 0, stretch: 0 }, eyebrows: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyes: { eyes: {
eyesType: 0, main: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelashesTop: 0, eyelashesTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelashesBottom: 0, eyelashesBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidTop: 0, eyelidTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidBottom: 0, eyelidBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyeliner: 0, eyeliner: { type: null, color: null },
pupil: 0, pupil: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
color: 0,
height: 0,
distance: 0,
rotation: 0,
size: 0,
stretch: 0,
}, },
nose: { type: 0, height: 0, size: 0 }, nose: { type: null, height: null, size: null },
lips: { type: 0, color: 0, height: 0, rotation: 0, size: 0, stretch: 0, hasLipstick: false }, lips: { type: null, color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
ears: { type: 0, height: 0, size: 0 }, ears: { type: null, height: null, size: null },
glasses: { type: 0, ringColor: 0, shadesColor: 0, height: 0, size: 0, stretch: 0 }, glasses: { type: null, ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
other: { other: {
wrinkles1: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, wrinkles1: { type: null, height: null, distance: null, size: null, stretch: null },
wrinkles2: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, wrinkles2: { type: null, height: null, distance: null, size: null, stretch: null },
beard: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, beard: { type: null, color: null },
moustache: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, moustache: { type: null, color: null, height: null, isFlipped: false, size: null, stretch: null },
goatee: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, goatee: { type: null, color: null },
mole: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, mole: { type: null, color: null, height: null, distance: null, size: null },
eyeShadow: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, eyeShadow: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
blush: { type: 0, color: 0, height: 0, distance: 0, size: 0, stretch: 0 }, blush: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
}, },
height: 0, height: null,
weight: 0, weight: null,
datingPreferences: [], datingPreferences: [],
voice: { speed: 0, pitch: 0, depth: 0, delivery: 0, tone: 0 }, voice: { speed: null, pitch: null, depth: null, delivery: null, tone: null },
personality: { movement: 0, speech: 0, energy: 0, thinking: 0, overall: 0 }, personality: { movement: null, speech: null, energy: null, thinking: null, overall: null },
}); });
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@ -378,6 +373,7 @@ export default function SubmitForm() {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<PortraitUpload setImage={setMiiPortraitUri} /> <PortraitUpload setImage={setMiiPortraitUri} />
<SwitchSubmitTutorialButton />
</div> </div>
</div> </div>
@ -416,7 +412,9 @@ export default function SubmitForm() {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<MiiEditor instructions={instructions} /> <MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton /> <SwitchSubmitTutorialButton />
<span className="text-xs text-zinc-400">Instructions are recommended, but not required.</span> <span className="text-xs text-zinc-400 text-center px-32">
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
</span>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
import { SwitchMiiInstructions } from "@/types"; import { SwitchMiiInstructions } from "@/types";
import React, { useState } from "react"; import React, { useState } from "react";
import { Icon } from "@iconify/react";
import HeadTab from "./tabs/head"; import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair"; import HairTab from "./tabs/hair";
@ -11,7 +12,6 @@ import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses"; import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other"; import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc"; import MiscTab from "./tabs/misc";
import { Icon } from "@iconify/react";
interface Props { interface Props {
instructions: React.RefObject<SwitchMiiInstructions>; instructions: React.RefObject<SwitchMiiInstructions>;
@ -28,7 +28,7 @@ export const TAB_ICONS: Record<Tab, string> = {
lips: "material-symbols-light:lips", lips: "material-symbols-light:lips",
ears: "ion:ear", ears: "ion:ear",
glasses: "solar:glasses-bold", glasses: "solar:glasses-bold",
other: "mingcute:head-ai-fill", other: "mdi:sparkles",
misc: "material-symbols:settings", misc: "material-symbols:settings",
}; };
@ -48,8 +48,6 @@ export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
export default function MiiEditor({ instructions }: Props) { export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head"); const [tab, setTab] = useState<Tab>("head");
const ActiveTab = TAB_COMPONENTS[tab];
return ( return (
<> <>
<div className="w-full aspect-video flex bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden"> <div className="w-full aspect-video flex bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">

View file

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
interface Props { interface Props {
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number }; target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number } | any;
} }
export default function NumberInputs({ target }: Props) { export default function NumberInputs({ target }: Props) {
@ -11,9 +11,11 @@ export default function NumberInputs({ target }: Props) {
const [size, setSize] = useState(0); const [size, setSize] = useState(0);
const [stretch, setStretch] = useState(0); const [stretch, setStretch] = useState(0);
if (!target) return null;
return ( return (
<div className="grid grid-rows-5 h-full"> <div className="grid grid-rows-5 min-h-0">
{target.height != undefined && ( {target.height !== undefined && (
<div className="w-full"> <div className="w-full">
<label htmlFor="height" className="text-xs"> <label htmlFor="height" className="text-xs">
Height Height
@ -34,7 +36,7 @@ export default function NumberInputs({ target }: Props) {
</div> </div>
)} )}
{target.distance != undefined && ( {target.distance !== undefined && (
<div className="w-full"> <div className="w-full">
<label htmlFor="distance" className="text-xs"> <label htmlFor="distance" className="text-xs">
Distance Distance
@ -55,7 +57,7 @@ export default function NumberInputs({ target }: Props) {
</div> </div>
)} )}
{target.rotation != undefined && ( {target.rotation !== undefined && (
<div className="w-full"> <div className="w-full">
<label htmlFor="rotation" className="text-xs"> <label htmlFor="rotation" className="text-xs">
Rotation Rotation
@ -76,7 +78,7 @@ export default function NumberInputs({ target }: Props) {
</div> </div>
)} )}
{target.size != undefined && ( {target.size !== undefined && (
<div className="w-full"> <div className="w-full">
<label htmlFor="size" className="text-xs"> <label htmlFor="size" className="text-xs">
Size Size
@ -97,7 +99,7 @@ export default function NumberInputs({ target }: Props) {
</div> </div>
)} )}
{target.stretch != undefined && ( {target.stretch !== undefined && (
<div className="w-full"> <div className="w-full">
<label htmlFor="stretch" className="text-xs"> <label htmlFor="stretch" className="text-xs">
Stretch Stretch

View file

@ -9,7 +9,7 @@ interface Props {
} }
export default function EyebrowsTab({ instructions }: Props) { export default function EyebrowsTab({ instructions }: Props) {
const [type, setType] = useState(0); const [type, setType] = useState(27);
const [color, setColor] = useState(0); const [color, setColor] = useState(0);
return ( return (
@ -23,7 +23,7 @@ export default function EyebrowsTab({ instructions }: Props) {
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector <TypeSelector
hasNoneOption hasNoneOption
length={35} length={43}
type={type} type={type}
setType={(i) => { setType={(i) => {
setType(i); setType(i);

View file

@ -8,21 +8,21 @@ interface Props {
instructions: React.RefObject<SwitchMiiInstructions>; instructions: React.RefObject<SwitchMiiInstructions>;
} }
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; length: number; colorsDisabled?: number[] }[] = [ const TABS: { name: keyof SwitchMiiInstructions["eyes"]; length: number; colorsDisabled?: boolean }[] = [
{ name: "eyesType", length: 50 }, { name: "main", length: 121 },
{ name: "eyelashesTop", length: 40 }, { name: "eyelashesTop", length: 6, colorsDisabled: true },
{ name: "eyelashesBottom", length: 20 }, { name: "eyelashesBottom", length: 2, colorsDisabled: true },
{ name: "eyelidTop", length: 10 }, { name: "eyelidTop", length: 3, colorsDisabled: true },
{ name: "eyelidBottom", length: 5 }, { name: "eyelidBottom", length: 3, colorsDisabled: true },
{ name: "eyeliner", length: 15 }, { name: "eyeliner", length: 2 },
{ name: "pupil", length: 3 }, { name: "pupil", length: 10, colorsDisabled: true },
]; ];
export default function OtherTab({ instructions }: Props) { export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
// One type/color state per tab // One type/color state per tab
const [types, setTypes] = useState<number[]>(Array(TABS.length).fill(0)); const [types, setTypes] = useState<number[]>([5, 0, 0, 0, 0, 0, 0]);
const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0)); const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0));
const currentTab = TABS[tab]; const currentTab = TABS[tab];
@ -34,7 +34,7 @@ export default function OtherTab({ instructions }: Props) {
return copy; return copy;
}); });
instructions.current.eyes[currentTab.name] = value; instructions.current.eyes[currentTab.name].type = value;
}; };
const setColor = (value: number) => { const setColor = (value: number) => {
@ -44,8 +44,7 @@ export default function OtherTab({ instructions }: Props) {
return copy; return copy;
}); });
// TODO: check in actual game, temp if (!currentTab.colorsDisabled) (instructions.current.eyes[currentTab.name] as { color: number }).color = value;
instructions.current.eyes.color = value;
}; };
return ( return (
@ -53,7 +52,7 @@ export default function OtherTab({ instructions }: Props) {
<div className="flex h-full"> <div className="flex h-full">
<div className="grow flex flex-col"> <div className="grow flex flex-col">
<div className="flex items-center h-8"> <div className="flex items-center h-8">
<h1 className="absolute font-bold text-xl">Other</h1> <h1 className="absolute font-bold text-xl">Eyes</h1>
<div className="flex justify-center grow"> <div className="flex justify-center grow">
<div className="rounded-2xl bg-orange-200"> <div className="rounded-2xl bg-orange-200">
@ -72,15 +71,15 @@ export default function OtherTab({ instructions }: Props) {
</div> </div>
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector hasNoneOption length={currentTab.length} type={types[tab]} setType={setType} /> <TypeSelector hasNoneOption={tab === 0} length={currentTab.length} type={types[tab]} setType={setType} />
</div> </div>
</div> </div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center"> <div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<div className={`${tab !== 0 ? "hidden" : "w-full"}`}> <div className={`${currentTab.colorsDisabled ? "hidden" : "w-full"}`}>
<ColorPicker color={colors[tab]} setColor={setColor} /> <ColorPicker color={colors[tab]} setColor={setColor} />
</div> </div>
<NumberInputs target={instructions.current.eyes} /> <NumberInputs target={instructions.current.eyes[currentTab.name]} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -24,7 +24,8 @@ export default function GlassesTab({ instructions }: Props) {
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector <TypeSelector
hasNoneOption hasNoneOption
length={50} isGlassesTab
length={58}
type={type} type={type}
setType={(i) => { setType={(i) => {
setType(i); setType(i);
@ -44,6 +45,7 @@ export default function GlassesTab({ instructions }: Props) {
/> />
<ColorPicker <ColorPicker
color={shadesColor} color={shadesColor}
disabled={type < 44}
setColor={(i) => { setColor={(i) => {
setShadesColor(i); setShadesColor(i);
instructions.current.glasses.shadesColor = i; instructions.current.glasses.shadesColor = i;

View file

@ -11,24 +11,41 @@ type Tab = "sets" | "bangs" | "back";
export default function HairTab({ instructions }: Props) { export default function HairTab({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("sets"); const [tab, setTab] = useState<Tab>("sets");
const [setsType, setSetsType] = useState(0); const [setsType, setSetsType] = useState<number | null>(43);
const [bangsType, setBangsType] = useState(0); const [bangsType, setBangsType] = useState<number | null>(null);
const [backType, setBackType] = useState(0); const [backType, setBackType] = useState<number | null>(null);
const [color, setColor] = useState(0); const [color, setColor] = useState(0);
const [subColor, setSubColor] = useState<number | null>(null); const [subColor, setSubColor] = useState<number | null>(null);
const [subColor2, setSubColor2] = useState<number | null>(null);
const [style, setStyle] = useState<number | null>(null);
const [isFlipped, setIsFlipped] = useState(false); const [isFlipped, setIsFlipped] = useState(false);
const type = tab === "sets" ? setsType : tab === "bangs" ? bangsType : backType; const type = tab === "sets" ? setsType : tab === "bangs" ? bangsType : backType;
const length = tab === "sets" ? 245 : tab === "bangs" ? 83 : 111;
const setType = (value: number) => { const setType = (value: number) => {
if (tab === "sets") { if (tab === "sets") {
setSetsType(value); setSetsType(value);
instructions.current.hair.setType = value; instructions.current.hair.setType = value;
// Clear bangs and back
setBangsType(null);
setBackType(null);
setSubColor2(null);
instructions.current.hair.bangsType = null;
instructions.current.hair.backType = null;
instructions.current.hair.subColor2 = null;
} else if (tab === "bangs") { } else if (tab === "bangs") {
setBangsType(value); setBangsType(value);
instructions.current.hair.bangsType = value; instructions.current.hair.bangsType = value;
// Clear set
setSetsType(null);
instructions.current.hair.setType = null;
} else { } else {
setBackType(value); setBackType(value);
instructions.current.hair.backType = value; instructions.current.hair.backType = value;
// Clear set
setSetsType(null);
instructions.current.hair.setType = null;
} }
}; };
@ -68,22 +85,7 @@ export default function HairTab({ instructions }: Props) {
</div> </div>
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector <TypeSelector length={length} type={type} setType={setType} />
length={50}
type={type}
setType={(i) => {
setType(i);
// Update ref
if (tab === "sets") {
instructions.current.hair.setType = i;
} else if (tab === "bangs") {
instructions.current.hair.bangsType = i;
} else if (tab === "back") {
instructions.current.hair.backType = i;
}
}}
/>
</div> </div>
</div> </div>
@ -97,23 +99,68 @@ export default function HairTab({ instructions }: Props) {
/> />
<div className="flex gap-1.5 items-center mb-2 w-full"> <div className="flex gap-1.5 items-center mb-2 w-full">
<input type="checkbox" id="subcolor" className="checkbox" checked={subColor !== null} onChange={(e) => setSubColor(e.target.checked ? 0 : null)} /> <input
type="checkbox"
id="subcolor"
className="checkbox"
checked={tab === "back" ? subColor2 !== null : subColor !== null}
onChange={(e) => {
if (tab === "back") {
setSubColor2(e.target.checked ? 0 : null);
instructions.current.hair.subColor2 = e.target.checked ? 0 : null;
} else {
setSubColor(e.target.checked ? 0 : null);
instructions.current.hair.subColor = e.target.checked ? 0 : null;
}
}}
/>
<label htmlFor="subcolor" className="text-xs"> <label htmlFor="subcolor" className="text-xs">
Sub color Sub color
</label> </label>
</div> </div>
<ColorPicker <ColorPicker
disabled={subColor === null} disabled={tab === "back" ? subColor2 === null : subColor === null}
color={subColor ? subColor : 0} color={tab === "back" ? (subColor2 ?? 0) : (subColor ?? 0)}
setColor={(i) => { setColor={(i) => {
setSubColor(i); if (tab === "back") {
instructions.current.hair.subColor = i; setSubColor2(i);
instructions.current.hair.subColor2 = i;
} else {
setSubColor(i);
instructions.current.hair.subColor = i;
}
}} }}
/> />
<p className="text-sm mb-1">Tying style</p>
<div className="grid grid-cols-3 w-full gap-0.5">
{Array.from({ length: 3 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
setStyle(i);
instructions.current.hair.style = i;
}}
className={`size-full aspect-square cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-lg ${style === i ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>
))}
</div>
<div className="flex gap-1.5 items-center w-full mt-auto"> <div className="flex gap-1.5 items-center w-full mt-auto">
<input type="checkbox" id="subcolor" className="checkbox" checked={isFlipped} onChange={(e) => setIsFlipped(e.target.checked)} /> <input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.hair.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs"> <label htmlFor="subcolor" className="text-xs">
Flip Flip
</label> </label>

View file

@ -10,8 +10,8 @@ interface Props {
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"]; const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
export default function HeadTab({ instructions }: Props) { export default function HeadTab({ instructions }: Props) {
const [color, setColor] = useState(108); const [color, setColor] = useState(109);
const [type, setType] = useState(0); const [type, setType] = useState(1);
return ( return (
<div className="relative grow p-3 pb-0!"> <div className="relative grow p-3 pb-0!">

View file

@ -9,8 +9,9 @@ interface Props {
} }
export default function LipsTab({ instructions }: Props) { export default function LipsTab({ instructions }: Props) {
const [type, setType] = useState(0); const [type, setType] = useState(1);
const [color, setColor] = useState(0); const [color, setColor] = useState(0);
const [hasLipstick, setHasLipstick] = useState(false);
return ( return (
<div className="relative grow p-3 pb-0!"> <div className="relative grow p-3 pb-0!">
@ -22,7 +23,7 @@ export default function LipsTab({ instructions }: Props) {
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector <TypeSelector
length={35} length={53}
type={type} type={type}
setType={(i) => { setType={(i) => {
setType(i); setType(i);
@ -41,6 +42,22 @@ export default function LipsTab({ instructions }: Props) {
}} }}
/> />
<NumberInputs target={instructions.current.lips} /> <NumberInputs target={instructions.current.lips} />
<div className="flex gap-1.5 items-center w-full mt-auto">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={hasLipstick}
onChange={(e) => {
setHasLipstick(e.target.checked);
instructions.current.lips.hasLipstick = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Lipstick
</label>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -93,10 +93,11 @@ export default function HeadTab({ instructions }: Props) {
<DatingPreferencesViewer <DatingPreferencesViewer
data={datingPreferences} data={datingPreferences}
onChecked={(e, gender) => { onChecked={(e, gender) => {
setDatingPreferences((prev) => setDatingPreferences((prev) => {
e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender), const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
); instructions.current.datingPreferences = updated;
instructions.current.datingPreferences = datingPreferences; return updated;
});
}} }}
/> />
</div> </div>
@ -132,7 +133,11 @@ export default function HeadTab({ instructions }: Props) {
<PersonalityViewer <PersonalityViewer
data={personality} data={personality}
onClick={(key, i) => { onClick={(key, i) => {
setPersonality((p) => ({ ...p, [key]: i })); setPersonality((p) => {
const updated = { ...p, [key]: i };
instructions.current.personality = updated;
return updated;
});
instructions.current.personality = personality; instructions.current.personality = personality;
}} }}
/> />

View file

@ -8,7 +8,7 @@ interface Props {
} }
export default function NoseTab({ instructions }: Props) { export default function NoseTab({ instructions }: Props) {
const [type, setType] = useState(0); const [type, setType] = useState(5);
return ( return (
<div className="relative grow p-3 pb-0!"> <div className="relative grow p-3 pb-0!">
@ -20,7 +20,7 @@ export default function NoseTab({ instructions }: Props) {
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector <TypeSelector
length={35} length={32}
type={type} type={type}
setType={(i) => { setType={(i) => {
setType(i); setType(i);

View file

@ -8,26 +8,26 @@ interface Props {
instructions: React.RefObject<SwitchMiiInstructions>; instructions: React.RefObject<SwitchMiiInstructions>;
} }
const TABS: { name: keyof SwitchMiiInstructions["other"]; length: number; colorsDisabled?: number[] }[] = [ const TABS: { name: keyof SwitchMiiInstructions["other"]; length: number }[] = [
{ name: "wrinkles1", length: 50 }, { name: "wrinkles1", length: 9 },
{ name: "wrinkles2", length: 40 }, { name: "wrinkles2", length: 15 },
{ name: "beard", length: 20 }, { name: "beard", length: 15 },
{ name: "moustache", length: 10 }, { name: "moustache", length: 16 },
{ name: "goatee", length: 5 }, { name: "goatee", length: 14 },
{ name: "mole", length: 15 }, { name: "mole", length: 2 },
{ name: "eyeShadow", length: 3 }, { name: "eyeShadow", length: 4 },
{ name: "blush", length: 8, colorsDisabled: [6] }, { name: "blush", length: 8 },
]; ];
export default function OtherTab({ instructions }: Props) { export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
// One type/color state per tab // One type/color state per tab
const [types, setTypes] = useState<number[]>(Array(TABS.length).fill(0)); const [types, setTypes] = useState<number[]>(Array(TABS.length).fill(0));
const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0)); const [colors, setColors] = useState<number[]>(Array(TABS.length).fill(0));
const currentTab = TABS[tab]; const currentTab = TABS[tab];
const isColorPickerDisabled = currentTab.colorsDisabled ? currentTab.colorsDisabled.includes(types[tab]) : false;
const setType = (value: number) => { const setType = (value: number) => {
setTypes((prev) => { setTypes((prev) => {
@ -73,13 +73,31 @@ export default function OtherTab({ instructions }: Props) {
</div> </div>
<div className="flex justify-center h-74 mt-auto"> <div className="flex justify-center h-74 mt-auto">
<TypeSelector hasNoneOption length={currentTab.length} type={types[tab]} setType={setType} /> <TypeSelector length={currentTab.length} type={types[tab]} setType={setType} />
</div> </div>
</div> </div>
<div className="shrink-0 w-21 pb-3 flex flex-col items-center"> <div className="shrink-0 w-21 pb-3 flex flex-col items-center">
<ColorPicker disabled={isColorPickerDisabled} color={colors[tab]} setColor={setColor} /> <ColorPicker color={colors[tab]} setColor={setColor} />
<NumberInputs target={instructions.current.other[currentTab.name]} /> <NumberInputs target={instructions.current.other[currentTab.name]} />
{tab === 3 && (
<div className="flex gap-1.5 items-center w-full mt-auto">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.other.moustache.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,22 +1,28 @@
import { Fragment } from "react/jsx-runtime";
interface Props { interface Props {
hasNoneOption?: boolean; hasNoneOption?: boolean;
isGlassesTab?: boolean;
length: number; length: number;
type: number; type: number | null;
setType: (type: number) => void; setType: (type: number) => void;
} }
export default function TypeSelector({ hasNoneOption, length, type, setType }: Props) { export default function TypeSelector({ hasNoneOption, isGlassesTab, length, type, setType }: Props) {
return ( return (
<div className="grid grid-cols-5 gap-1 w-fit overflow-y-auto h-fit max-h-full"> <div className="grid grid-cols-5 gap-1 w-fit overflow-y-auto h-fit max-h-full">
{Array.from({ length }).map((_, i) => ( {Array.from({ length }).map((_, i) => (
<button <Fragment key={i}>
type="button" <button
key={i} type="button"
onClick={() => setType(i)} onClick={() => setType(i)}
className={`size-12 cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-xl ${type === i ? "bg-orange-400!" : ""} ${hasNoneOption && i === 0 ? "text-md" : "text-2xl"}`} className={`size-12 cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-xl ${type === i ? "bg-orange-400!" : ""} ${hasNoneOption && i === 0 ? "text-md" : "text-2xl"}`}
> >
{hasNoneOption ? (i === 0 ? "None" : i) : i + 1} {hasNoneOption ? (i === 0 ? "None" : i + 1) : i + 1}
</button> </button>
{isGlassesTab && i === 43 && <div />}
</Fragment>
))} ))}
</div> </div>
); );

View file

@ -143,7 +143,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
alt="tutorial thumbnail" alt="tutorial thumbnail"
width={128} width={128}
height={128} height={128}
className="rounded-lg border-2 border-zinc-300" className="rounded-lg border-2 border-zinc-300 object-cover"
/> />
<p className="mt-2">{tutorial.title}</p> <p className="mt-2">{tutorial.title}</p>
{/* Set opacity to 0 to keep height the same with other tutorials */} {/* Set opacity to 0 to keep height the same with other tutorials */}

View file

@ -1,42 +0,0 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/adding-mii/step2.png" },
{ text: "3. Press 'Scan QR Code'", imageSrc: "/tutorial/adding-mii/step3.png" },
{ text: "4. Click on the QR code below the Mii's image", imageSrc: "/tutorial/adding-mii/step4.png" },
{ text: "5. Scan with your 3DS", imageSrc: "/tutorial/adding-mii/step5.png" },
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'Mii List'", imageSrc: "/tutorial/allow-copying/step2.png" },
{ text: "3. Select and edit the Mii you wish to submit", imageSrc: "/tutorial/allow-copying/step3.png" },
{ text: "4. Click 'Other Settings' in the information screen", imageSrc: "/tutorial/allow-copying/step4.png" },
{ text: "5. Click on 'Don't Allow' under the 'Copying' text", imageSrc: "/tutorial/allow-copying/step5.png" },
{ text: "6. Press 'Allow'", imageSrc: "/tutorial/allow-copying/step6.png" },
{ text: "7. Confirm the edits to the Mii", imageSrc: "/tutorial/allow-copying/step7.png" },
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/create-qr-code/step2.png" },
{ text: "3. Press 'Create QR Code'", imageSrc: "/tutorial/create-qr-code/step3.png" },
{ text: "4. Select and press 'OK' on the Mii you wish to submit", imageSrc: "/tutorial/create-qr-code/step4.png" },
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/create-qr-code/step5.png",
},
{
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",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function SwitchAddMiiTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Press X to open the menu, then select 'Add a Mii'",
imageSrc: "/tutorial/switch/step1.jpg",
},
{
text: "2. Press 'From scratch' and choose the Male template (instructions may be slightly inaccurate if you select Female)",
imageSrc: "/tutorial/switch/step2.jpg",
},
{
text: "3. Follow all instructions (not all instructions will be there, check next slide for more)",
imageSrc: "/tutorial/switch/step3.jpg",
},
{
text: "4. If the instructions have height, distance, etc. the value will be relative to how many times to click the button - positive for up/left, negative for down/right",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -1,61 +0,0 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
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(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/switch/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/switch/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>
);
}

View file

@ -18,32 +18,27 @@ export default function SubmitTutorialButton() {
<Tutorial <Tutorial
tutorials={[ tutorials={[
{ {
title: "Create QR Code", title: "Mii Instructions",
thumbnail: "/tutorial/switch/create-qr-code/thumbnail.png",
steps: [ steps: [
{ {
text: "1. Enter the town hall", text: "1. Press X to open the menu, then select 'Add a Mii' (or 'Residents' if you're submitting an existing Mii)",
imageSrc: "/tutorial/switch/step1.png", imageSrc: "/tutorial/switch/step1.jpg",
}, },
{ {
text: "2. Go into 'QR Code'", text: "2. Press 'From scratch' and choose the Male template (instructions may be slightly inaccurate if you select Female, it's fine if you change all defaults)",
imageSrc: "/tutorial/switch/create-qr-code/step2.png", imageSrc: "/tutorial/switch/step2.jpg",
}, },
{ {
text: "3. Press 'Create QR Code'", text: "3. Customize your Mii to your liking",
imageSrc: "/tutorial/switch/create-qr-code/step3.png", imageSrc: "/tutorial/switch/step3.jpg",
}, },
{ {
text: "4. Select and press 'OK' on the Mii you wish to submit", text: "4. All instructions are optional but if you want to add height, distance, etc. the value will be relative to how many times you clicked the button - positive for up/left, negative for down/right",
imageSrc: "/tutorial/switch/create-qr-code/step4.png", imageSrc: "/tutorial/switch/step4.jpg",
}, },
{ {
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.", text: "5. Upload instructions, then screenshot the Mii for the portrait (feel free to crop it)",
imageSrc: "/tutorial/switch/create-qr-code/step5.png", imageSrc: "/tutorial/switch/step5.jpg",
},
{
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",
}, },
{ type: "finish" }, { type: "finish" },
], ],

View file

@ -1,46 +0,0 @@
"use client";
import { useState } from "react";
import { redirect } from "next/navigation";
import { usernameSchema } from "@/lib/schemas";
import SubmitButton from "./submit-button";
export default function UsernameForm() {
const [username, setUsername] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) setError(parsed.error.issues[0].message);
const response = await fetch("/api/auth/username", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
const { error } = await response.json();
setError(error);
return;
}
redirect("/");
};
return (
<form className="flex flex-col items-center">
<input
type="text"
placeholder="Type your username..."
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="pill input w-96 mb-2"
/>
<SubmitButton onClick={handleSubmit} />
{error && <p className="text-red-400 font-semibold mt-4">Error: {error}</p>}
</form>
);
}

View file

@ -15,7 +15,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async session({ session, user }) { async session({ session, user }) {
if (user) { if (user) {
session.user.id = user.id; session.user.id = user.id;
session.user.username = user.username;
session.user.email = user.email; session.user.email = user.email;
} }
return session; return session;

View file

@ -130,10 +130,9 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
// Load assets concurrently // Load assets concurrently
const [miiImage, qrCodeImage, fonts] = await Promise.all([ const [miiImage, qrCodeImage, fonts] = await Promise.all([
// Read and convert the .webp images to .png (because satori doesn't support it) // Read and convert the images to data URI
fs.readFile(path.join(miiUploadsDirectory, "mii.webp")).then((buffer) => fs.readFile(path.join(miiUploadsDirectory, "mii.png")).then((buffer) =>
sharp(buffer) sharp(buffer)
.png()
// extend to fix shadow bug on landscape pictures // extend to fix shadow bug on landscape pictures
.extend({ .extend({
left: 16, left: 16,
@ -144,9 +143,8 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`), .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
), ),
mii.platform === "THREE_DS" mii.platform === "THREE_DS"
? fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) => ? fs.readFile(path.join(miiUploadsDirectory, "qr-code.png")).then((buffer) =>
sharp(buffer) sharp(buffer)
.png()
.toBuffer() .toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`), .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
) )
@ -240,8 +238,6 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
// Store the file // Store the file
try { 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"); const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer); await fs.writeFile(fileLocation, buffer);
} catch (error) { } catch (error) {

View file

@ -102,7 +102,7 @@ export class RateLimit {
async handle(): Promise<NextResponse<object | unknown> | undefined> { async handle(): Promise<NextResponse<object | unknown> | undefined> {
const session = await auth(); const session = await auth();
const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0]; const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0];
const identifier = (session ? session.user.id : ip) ?? "anonymous"; const identifier = (session ? session.user?.id : ip) ?? "anonymous";
this.data = await this.check(identifier); this.data = await this.check(identifier);

View file

@ -74,43 +74,35 @@ export const searchSchema = z.object({
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(), seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
}); });
// Account Info export const userNameSchema = z
export const usernameSchema = z
.string() .string()
.trim() .trim()
.min(3, "Username must be at least 3 characters long") .min(2, { error: "Name must be at least 2 characters long" })
.max(20, "Username cannot be more than 20 characters long") .max(64, { error: "Name cannot be more than 64 characters long" })
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
export const displayNameSchema = z
.string()
.trim()
.min(2, { error: "Display name must be at least 2 characters long" })
.max(64, { error: "Display name cannot be more than 64 characters long" })
.regex(/^[a-zA-Z0-9-_. ']+$/, { .regex(/^[a-zA-Z0-9-_. ']+$/, {
error: "Display name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", error: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
}); });
const colorSchema = z.number().int().min(0).max(107).optional(); const colorSchema = z.number().int().min(0).max(107).optional();
const geometrySchema = z.number().int().min(-5).max(5).optional(); const geometrySchema = z.number().int().min(-10).max(10).optional();
export const switchMiiInstructionsSchema = z export const switchMiiInstructionsSchema = z
.object({ .object({
head: z.object({ type: z.number().int().min(1).max(16).optional(), skinColor: z.number().int().min(0).max(121).optional() }).optional(), head: z.object({ type: z.number().int().min(0).max(15).optional(), skinColor: z.number().int().min(0).max(121).optional() }).optional(),
hair: z hair: z
.object({ .object({
setType: z.number().int().min(0).max(25).optional(), setType: z.number().int().min(0).max(244).optional(),
bangsType: z.number().int().min(0).max(25).optional(), bangsType: z.number().int().min(0).max(82).optional(),
backType: z.number().int().min(0).max(25).optional(), backType: z.number().int().min(0).max(110).optional(),
color: colorSchema, color: colorSchema,
subColor: colorSchema, subColor: colorSchema,
style: z.number().int().min(0).max(3).optional(), style: z.number().int().min(1).max(3).optional(),
isFlipped: z.boolean().optional(), isFlipped: z.boolean().optional(),
}) })
.optional(), .optional(),
eyebrows: z eyebrows: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(42).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -121,31 +113,85 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
eyes: z eyes: z
.object({ .object({
eyesType: z.number().int().min(0).max(25).optional(), main: z
eyelashesTop: z.number().int().min(0).max(6).optional(), .object({
eyelashesBottom: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(120).optional(),
eyelidTop: z.number().int().min(0).max(2).optional(), color: colorSchema,
eyelidBottom: z.number().int().min(0).max(25).optional(), height: geometrySchema,
eyeliner: z.number().int().min(0).max(25).optional(), distance: geometrySchema,
pupil: z.number().int().min(0).max(9).optional(), rotation: geometrySchema,
color: colorSchema, size: geometrySchema,
height: geometrySchema, stretch: geometrySchema,
distance: geometrySchema, })
rotation: geometrySchema, .optional(),
size: geometrySchema, eyelashesTop: z
stretch: geometrySchema, .object({
type: z.number().int().min(0).max(5).optional(),
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyelashesBottom: z
.object({
type: z.number().int().min(0).max(1).optional(),
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyelidTop: z
.object({
type: z.number().int().min(0).max(2).optional(),
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyelidBottom: z
.object({
type: z.number().int().min(0).max(2).optional(),
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
eyeliner: z
.object({
type: z.number().int().min(0).max(1).optional(),
color: colorSchema,
})
.optional(),
pupil: z
.object({
type: z.number().int().min(0).max(9).optional(),
height: geometrySchema,
distance: geometrySchema,
rotation: geometrySchema,
size: geometrySchema,
stretch: geometrySchema,
})
.optional(),
}) })
.optional(), .optional(),
nose: z nose: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(31).optional(),
height: geometrySchema, height: geometrySchema,
size: geometrySchema, size: geometrySchema,
}) })
.optional(), .optional(),
lips: z lips: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(52).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
rotation: geometrySchema, rotation: geometrySchema,
@ -163,7 +209,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
glasses: z glasses: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(57).optional(),
ringColor: colorSchema, ringColor: colorSchema,
shadesColor: colorSchema, shadesColor: colorSchema,
height: geometrySchema, height: geometrySchema,
@ -175,7 +221,7 @@ export const switchMiiInstructionsSchema = z
.object({ .object({
wrinkles1: z wrinkles1: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(8).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -185,7 +231,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
wrinkles2: z wrinkles2: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(14).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -195,7 +241,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
beard: z beard: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(14).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -205,7 +251,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
moustache: z moustache: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(15).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -215,7 +261,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
goatee: z goatee: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(13).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -225,7 +271,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
mole: z mole: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(1).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -235,7 +281,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
eyeShadow: z eyeShadow: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(3).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -245,7 +291,7 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
blush: z blush: z
.object({ .object({
type: z.number().int().min(0).max(25).optional(), type: z.number().int().min(0).max(7).optional(),
color: colorSchema, color: colorSchema,
height: geometrySchema, height: geometrySchema,
distance: geometrySchema, distance: geometrySchema,
@ -269,11 +315,11 @@ export const switchMiiInstructionsSchema = z
.optional(), .optional(),
personality: z personality: z
.object({ .object({
movement: z.number().int().min(1).max(8).optional(), movement: z.number().int().min(0).max(5).optional(),
speech: z.number().int().min(1).max(8).optional(), speech: z.number().int().min(0).max(5).optional(),
energy: z.number().int().min(1).max(8).optional(), energy: z.number().int().min(0).max(5).optional(),
thinking: z.number().int().min(1).max(8).optional(), thinking: z.number().int().min(0).max(5).optional(),
overall: z.number().int().min(1).max(8).optional(), overall: z.number().int().min(0).max(5).optional(),
}) })
.optional(), .optional(),
}) })

260
src/types.d.ts vendored
View file

@ -1,167 +1,185 @@
import { MiiGender, Prisma } from "@prisma/client"; import { MiiGender, Prisma } from "@prisma/client";
import { DefaultSession } from "next-auth"; import { DefaultSession } from "next-auth";
declare module "next-auth" { // Some types have different options disabled, we're ignoring them for now
interface Session {
user: {
username?: string;
} & DefaultSession["user"];
}
interface User {
username?: string;
}
}
// All color properties are assumed to be the same 108 colors
interface SwitchMiiInstructions { interface SwitchMiiInstructions {
head: { head: {
type: number; // 16 types type: number | null; // 16 types, default is 2
skinColor: number; // additional 14 are not in color menu skinColor: number | null; // Additional 14 are not in color menu, default is 2
}; };
hair: { hair: {
setType: number; // at least 25 setType: number | null; // 245 types, default is 43
bangsType: number; // at least 25 bangsType: number | null; // 83 types, default is none, if a set is selected, set bangs and back to none and vice-versa
backType: number; // at least 25 backType: number | null; // 111 types, default is none, same here (set related)
color: number; color: number | null;
subColor: number; subColor: number | null; // Default is none
style: number; // is this different for each hair? subColor2: number | null; // Only used when bangs/back is selected
isFlipped: boolean; // is this different for bangs/back? style: number | null; // is this different for each hair?
isFlipped: boolean; // Only for sets and fringe
}; };
eyebrows: { eyebrows: {
type: number; // 0 is None, at least 25 (including None) type: number | null; // 1 is None, 43 types, default is 28
color: number; color: number | null;
height: number; height: number | null;
distance: number; distance: number | null;
rotation: number; rotation: number | null;
size: number; size: number | null;
stretch: number; stretch: number | null;
}; };
eyes: { eyes: {
eyesType: number; // At least 25 main: {
eyelashesTop: number; // 6 types type: number | null; // 1 is None, 121 types default is 6
eyelashesBottom: number; // unknown color: number | null;
eyelidTop: number; // 0 is None, 2 additional types height: number | null;
eyelidBottom: number; // unknown distance: number | null;
eyeliner: number; // unknown rotation: number | null;
pupil: number; // 0 is default, 9 additional types size: number | null;
color: number; // is this same as hair? stretch: number | null;
height: number; };
distance: number; eyelashesTop: {
rotation: number; type: number | null; // 6 types, default is 1
size: number; height: number | null;
stretch: number; distance: number | null;
rotation: number | null;
size: number | null;
stretch: number | null;
};
eyelashesBottom: {
type: number | null; // 2 types, default is 1
height: number | null;
distance: number | null;
rotation: number | null;
size: number | null;
stretch: number | null;
};
eyelidTop: {
type: number | null; // 3 types, default is 1
height: number | null;
distance: number | null;
rotation: number | null;
size: number | null;
stretch: number | null;
};
eyelidBottom: {
type: number | null; // 3 types, default is 1
height: number | null;
distance: number | null;
rotation: number | null;
size: number | null;
stretch: number | null;
};
eyeliner: {
type: number | null; // 2 types, default is 1
color: number | null;
};
pupil: {
type: number | null; // 10 types, default is 1
height: number | null;
distance: number | null;
rotation: number | null;
size: number | null;
stretch: number | null;
};
}; };
nose: { nose: {
type: number; // 0 is None, at least 24 additional type: number | null; // 1 is None, 32 types, default is 6
height: number; height: number | null;
size: number; size: number | null;
}; };
lips: { lips: {
type: number; // 0 is None, at least 24 additional type: number | null; // 1 is None, 53 types, default is 2
color: number; // is this same as hair? color: number | null;
height: number; height: number | null;
rotation: number; rotation: number | null;
size: number; size: number | null;
stretch: number; stretch: number | null;
hasLipstick: boolean; // is this what it's called? hasLipstick: boolean;
}; };
ears: { ears: {
type: number; // 0 is Default, 4 additional type: number | null; // 5 types, default is 1
height: number; height: number | null; // Does not work for default
size: number; size: number | null; // Does not work for default
}; };
glasses: { glasses: {
type: number; // NOTE: THERE IS A GAP!!! 0 is None, at least 29 additional type: number | null; // NOTE: THERE IS A GAP AT 40!!! 1 is None, 58 types, default is 1
ringColor: number; // i'm assuming based off icon ringColor: number | null;
shadesColor: number; // i'm assuming based off icon shadesColor: number | null; // Only works after gap
height: number; height: number | null;
size: number; size: number | null;
stretch: number; stretch: number | null;
}; };
other: { other: {
// names were assumed // names were assumed
wrinkles1: { wrinkles1: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 9 types, default is 1
color: number; // is this same as hair? height: number | null;
height: number; distance: number | null;
distance: number; size: number | null;
size: number; stretch: number | null;
stretch: number;
}; };
wrinkles2: { wrinkles2: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 15 types, default is 1
color: number; // is this same as hair? height: number | null;
height: number; distance: number | null;
distance: number; size: number | null;
size: number; stretch: number | null;
stretch: number;
}; };
beard: { beard: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 15 types, default is 1
color: number; // is this same as hair? color: number | null;
height: number;
distance: number;
size: number;
stretch: number;
}; };
moustache: { moustache: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 16 types, default is 1
color: number; // is this same as hair? color: number | null; // is this same as hair?
height: number; height: number | null;
distance: number; isFlipped: boolean;
size: number; size: number | null;
stretch: number; stretch: number | null;
}; };
goatee: { goatee: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 14 types, default is 1
color: number; // is this same as hair? color: number | null;
height: number;
distance: number;
size: number;
stretch: number;
}; };
mole: { mole: {
type: number; // 0 is None, at least BLANK additional type: number | null; // 2 types, default is 1
color: number; // is this same as hair? color: number | null; // is this same as hair?
height: number; height: number | null;
distance: number; distance: number | null;
size: number; size: number | null;
stretch: number;
}; };
eyeShadow: { eyeShadow: {
type: number; // 0 is None, at least 3 additional type: number | null; // 4 types, default is 1
color: number; // is this same as hair? color: number | null;
height: number; height: number | null;
distance: number; distance: number | null;
size: number; size: number | null;
stretch: number; stretch: number | null;
}; };
blush: { blush: {
type: number; // 0 is None, at least 7 additional type: number | null; // 8 types, default is 1
color: number; // is this same as hair? color: number | null;
height: number; height: number | null;
distance: number; distance: number | null;
size: number; size: number | null;
stretch: number; stretch: number | null;
}; };
}; };
// makeup, use video? // makeup, use video?
height: number; height: number | null;
weight: number; weight: number | null;
datingPreferences: MiiGender[]; datingPreferences: MiiGender[];
voice: { voice: {
speed: number; speed: number | null;
pitch: number; pitch: number | null;
depth: number; depth: number | null;
delivery: number; delivery: number | null;
tone: number; // 1 to 6 tone: number | null; // 1 to 6
}; };
personality: { personality: {
movement: number; // 8 levels, slow to quick movement: number | null; // 8 levels, slow to quick
speech: number; // 8 levels, polite to honest speech: number | null; // 8 levels, polite to honest
energy: number; // 8 levels, flat to varied energy: number | null; // 8 levels, flat to varied
thinking: number; // 8 levels, serious to chill thinking: number | null; // 8 levels, serious to chill
overall: number; // 8 levels, normal to quirky overall: number | null; // 8 levels, normal to quirky
}; };
} }