Compare commits

...

25 commits

Author SHA1 Message Date
trafficlunar
92071465b6
Merge 0f038bf348 into 8fffa1c9cc 2026-03-25 21:38:33 +00:00
0f038bf348 fix: even more fixes 2026-03-25 21:38:18 +00:00
86c655d7d0 fix: bunch of fixes 2026-03-25 21:07:28 +00:00
74139dd54e feat: use data from demo 2026-03-25 19:57:58 +00:00
22911804c0 Merge branch 'main' into feat/living-the-dream-manually 2026-03-25 16:49:28 +00:00
8fffa1c9cc feat: remove usernames 2026-03-24 22:09:00 +00:00
6453788ec3 chore: update packages 2026-03-19 21:35:45 +00:00
320f7fbc7e fix: disable optimizing images
for 22fb3a2e
2026-03-14 13:17:02 +00:00
69402d6521 fix: add dockerignore 2026-03-14 13:05:56 +00:00
22fb3a2e30 feat: abandon webp 2026-03-13 22:07:10 +00:00
13941e849c feat: view instructions on mii page 2026-02-28 16:57:43 +00:00
5995afe3db feat: metadata images for switch platform
also some other changes
2026-02-28 12:33:36 +00:00
e31141ea39 feat: fake mii editor 2026-02-27 23:27:40 +00:00
d45eb07879 feat: nonbinary miis 2026-02-24 17:24:33 +00:00
0b1516e930 Merge branch 'main' into feat/living-the-dream-qr-code 2026-02-24 16:36:56 +00:00
118739041f Merge branch 'main' into feat/living-the-dream-qr-code 2026-02-20 15:23:11 +00:00
cd34fb983d feat: random stuff 2026-01-02 16:36:59 +00:00
2af1bf18a6 Merge branch 'main' into feat/living-the-dream-qr-code 2026-01-02 16:25:13 +00:00
76fecca011 fix: remove island name from metadata in mii page
only automatically works for 3DS and I don't want to ask people for an
island name if on switch
2025-09-15 22:20:30 +01:00
f9dd7a396c style: fix responsiveness of filter menu 2025-09-14 17:17:41 +01:00
93e26b8937 fix: 'metadata' type images stretching mii portrait for switch miis 2025-09-14 17:11:59 +01:00
43c67d75a9 feat: platform filter, filtering redesign, show platform on mii pages 2025-09-14 15:27:13 +01:00
90a6b741be feat: groundwork for different platform tutorials 2025-09-14 12:36:11 +01:00
e1b269d99b Merge branch 'main' into feat/living-the-dream-qr-code 2025-09-14 12:25:52 +01:00
20f1c51f0c feat: groundwork for 'living the dream' mii submissions
Based on the screenshots from yesterday's Nintendo Direct, it is
presumed that the Mii editor in "Living the Dream" is similar to
Miitopia's one.

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

Note: I don't know if Nintendo will use access codes (as was the case
with Miitopia) therefore, as a precaution, another branch will be
created in anticipation for that.
2025-09-13 15:03:12 +01:00
131 changed files with 4214 additions and 1535 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,9 @@
-- CreateEnum
CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS');
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS',
ALTER COLUMN "firstName" DROP NOT NULL,
ALTER COLUMN "lastName" DROP NOT NULL,
ALTER COLUMN "islandName" DROP NOT NULL,
ALTER COLUMN "allowedCopying" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "MiiGender" ADD VALUE 'NONBINARY';

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "instructions" JSONB;

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[]
@ -69,17 +67,20 @@ model Session {
} }
model Mii { model Mii {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
firstName String name String @db.VarChar(64)
lastName String imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS)
instructions Json?
firstName String?
lastName String?
gender MiiGender? gender MiiGender?
islandName String islandName String?
allowedCopying Boolean? allowedCopying Boolean?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -154,9 +155,15 @@ model Punishment {
@@map("punishments") @@map("punishments")
} }
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender { enum MiiGender {
MALE MALE
FEMALE FEMALE
NONBINARY
} }
enum ReportType { enum ReportType {

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

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) {
@ -139,7 +139,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
} else if (description === undefined) { } else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image // If images or description were not changed, regenerate the metadata image
await generateMetadataImage(updatedMii, updatedMii.user.name!); try {
await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
} }
return rateLimit.sendResponse({ success: true }); return rateLimit.sendResponse({ success: true });

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

@ -8,46 +8,75 @@ import sharp from "sharp";
import qrcode from "qrcode-generator"; import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity"; import { profanity } from "@2toad/profanity";
import { MiiGender } from "@prisma/client"; import { MiiGender, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images"; import { generateMetadataImage, validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes"; import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii"; import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
import { SwitchMiiInstructions } from "@/types";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z.object({ const submitSchema = z
name: nameSchema, .object({
tags: tagsSchema, platform: z.enum(MiiPlatform).default("THREE_DS"),
description: z.string().trim().max(256).optional(), name: nameSchema,
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { tags: tagsSchema,
error: "QR code size is not a valid Tomodachi Life QR code", description: z.string().trim().max(256).optional(),
}),
image1: z.union([z.instanceof(File), z.any()]).optional(), // Switch
image2: z.union([z.instanceof(File), z.any()]).optional(), gender: z.enum(MiiGender).default("MALE"),
image3: z.union([z.instanceof(File), z.any()]).optional(), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
}); instructions: switchMiiInstructionsSchema,
// QR code
qrBytesRaw: z
.array(z.number(), { error: "A QR code is required" })
.length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
})
.nullish(),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
})
// This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, gender and miiPortraitImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Gender, Mii portrait image, and instructions are required for Switch platform",
path: ["gender", "miiPortraitImage", "instructions"],
},
);
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, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json(); const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409); if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
// Parse data // Parse tags and QR code as JSON
const formData = await request.formData(); const formData = await request.formData();
let rawTags: string[]; let rawTags: string[];
@ -62,18 +91,79 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400); return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
} }
// Minify instructions to save space and improve user experience
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
if (formData.get("platform") === "SWITCH") {
const DEFAULT_ZERO_FIELDS = new Set(["height", "distance", "rotation", "size", "stretch"]);
function minify(object: Partial<SwitchMiiInstructions>): Partial<SwitchMiiInstructions> {
for (const key in object) {
const value = object[key as keyof SwitchMiiInstructions];
if (!value || (DEFAULT_ZERO_FIELDS.has(key) && value === 0)) {
delete object[key as keyof SwitchMiiInstructions];
continue;
}
if (typeof value === "object" && !Array.isArray(value)) {
minify(value as Partial<SwitchMiiInstructions>);
if (Object.keys(value).length === 0) {
delete object[key as keyof SwitchMiiInstructions];
}
}
}
return object;
}
minifiedInstructions = minify(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
}
// Parse and check all submission info
const parsed = submitSchema.safeParse({ const parsed = submitSchema.safeParse({
platform: formData.get("platform"),
name: formData.get("name"), name: formData.get("name"),
tags: rawTags, tags: rawTags,
description: formData.get("description"), description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
miiPortraitImage: formData.get("miiPortraitImage"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw, qrBytesRaw: rawQrBytesRaw,
image1: formData.get("image1"), image1: formData.get("image1"),
image2: formData.get("image2"), image2: formData.get("image2"),
image3: formData.get("image3"), image3: formData.get("image3"),
}); });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); if (!parsed.success) {
const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data; const error = parsed.error.issues[0].message;
const issues = parsed.error.issues;
const hasInstructionsErrors = issues.some((issue) => issue.path[0] === "instructions");
if (hasInstructionsErrors) {
Sentry.captureException(error, {
extra: { issues, rawInstructions: formData.get("instructions"), stage: "submit-instructions" },
});
}
return rateLimit.sendResponse({ error }, 400);
}
const {
platform,
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
gender,
miiPortraitImage,
instructions,
image1,
image2,
image3,
} = parsed.data;
// Censor potential inappropriate words // Censor potential inappropriate words
const name = profanity.censor(uncensoredName); const name = profanity.censor(uncensoredName);
@ -81,43 +171,60 @@ export async function POST(request: NextRequest) {
const description = uncensoredDescription && profanity.censor(uncensoredDescription); const description = uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files // Validate image files
const images: File[] = []; const customImages: File[] = [];
for (const img of [image1, image2, image3]) { for (const img of [image1, image2, image3]) {
if (!img) continue; if (!img) continue;
const imageValidation = await validateImage(img); const imageValidation = await validateImage(img);
if (imageValidation.valid) { if (imageValidation.valid) {
images.push(img); customImages.push(img);
} else { } else {
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
} }
} }
const qrBytes = new Uint8Array(qrBytesRaw); // Check Mii portrait image as well (Switch)
if (platform === "SWITCH") {
const imageValidation = await validateImage(miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
// Convert QR code to JS const qrBytes = new Uint8Array(qrBytesRaw ?? []);
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try { // Convert QR code to JS (3DS)
conversion = convertQrCode(qrBytes); let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined;
} catch (error) { if (platform === "THREE_DS") {
Sentry.captureException(error, { extra: { stage: "qr-conversion" } }); try {
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400); conversion = convertQrCode(qrBytes);
} catch (error) {
Sentry.captureException(error, { extra: { stage: "qr-conversion" } });
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
}
} }
// Create Mii in database // 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,
name, name,
tags, tags,
description, description,
gender: gender ?? "MALE",
firstName: conversion.tomodachiLifeMii.firstName, // Automatically detect certain information if on 3DS
lastName: conversion.tomodachiLifeMii.lastName, ...(platform === "THREE_DS"
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, ? conversion && {
islandName: conversion.tomodachiLifeMii.islandName, firstName: conversion.tomodachiLifeMii.firstName,
allowedCopying: conversion.mii.allowCopying, lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
}
: {
instructions: minifiedInstructions,
}),
}, },
}); });
@ -125,69 +232,75 @@ export async function POST(request: NextRequest) {
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true }); await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii
let studioBuffer: Buffer;
try { try {
const studioUrl = conversion.mii.studioUrl({ width: 512 }); let portraitBuffer: Buffer | undefined;
const studioResponse = await fetch(studioUrl);
if (!studioResponse.ok) { // Download the image of the Mii (3DS)
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl!);
if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
}
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
} }
const studioArrayBuffer = await studioResponse.arrayBuffer(); if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
studioBuffer = Buffer.from(studioArrayBuffer); const pngBuffer = await sharp(portraitBuffer).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.png");
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 } });
console.error("Failed to download Mii image:", error); console.error("Failed to download/store Mii portrait:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } }); Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
} }
try { if (platform === "THREE_DS") {
// Compress and store try {
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); // Generate a new QR code for aesthetic reasons
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
await fs.writeFile(studioFileLocation, studioWebpBuffer); // Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Generate a new QR code for aesthetic reasons // Compress and store
const byteString = String.fromCharCode(...qrBytes); const codePngBuffer = await sharp(codeBuffer).png({ quality: 85 }).toBuffer();
const generatedCode = qrcode(0, "L"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.png");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code await fs.writeFile(codeFileLocation, codePngBuffer);
const codeDataUrl = generatedCode.createDataURL(); } catch (error) {
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); // Clean up if something went wrong
const codeBuffer = Buffer.from(codeBase64, "base64"); await prisma.mii.delete({ where: { id: miiRecord.id } });
// Compress and store console.error("Error processing Mii files:", error);
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
}
await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
} }
// Compress and store user images // Compress and store user images
try { try {
await Promise.all( await Promise.all(
images.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);
}), }),
); );
@ -197,7 +310,7 @@ export async function POST(request: NextRequest) {
id: miiRecord.id, id: miiRecord.id,
}, },
data: { data: {
imageCount: images.length, imageCount: customImages.length,
}, },
}); });
} catch (error) { } catch (error) {

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

@ -91,6 +91,23 @@ body {
@apply opacity-100 scale-100; @apply opacity-100 scale-100;
} }
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
/* Scrollbar */ /* Scrollbar */
/* Firefox */ /* Firefox */
* { * {
@ -101,3 +118,35 @@ body {
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: #ff8903; background: #ff8903;
} }
/* Range input */
input[type="range"] {
@apply appearance-none bg-transparent not-disabled:cursor-pointer;
}
/* Track */
input[type="range"]::-webkit-slider-runnable-track {
@apply h-2 bg-orange-200 border-2 border-orange-400 rounded-full;
}
input[type="range"]::-moz-range-track {
@apply h-1 bg-orange-200 border-2 border-orange-400 rounded-full;
}
/* Thumb */
input[type="range"]::-webkit-slider-thumb {
@apply appearance-none size-4 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition -mt-1.5;
}
input[type="range"]::-moz-range-thumb {
@apply size-3.5 bg-orange-400 border-2 border-orange-500 rounded-full shadow-md transition;
}
/* Hover */
input[type="range"]:hover::-webkit-slider-thumb {
@apply not-disabled:bg-orange-500;
}
input[type="range"]:hover::-moz-range-thumb {
@apply not-disabled:bg-orange-500;
}

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

@ -7,14 +7,18 @@ import { Icon } from "@iconify/react";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { MiiPlatform } from "@prisma/client";
import LikeButton from "@/components/like-button"; import LikeButton from "@/components/like-button";
import ImageViewer from "@/components/image-viewer"; import ImageViewer from "@/components/image-viewer";
import DeleteMiiButton from "@/components/delete-mii"; import DeleteMiiButton from "@/components/mii/delete-mii-button";
import ShareMiiButton from "@/components/share-mii-button"; import ShareMiiButton from "@/components/mii/share-mii-button";
import ScanTutorialButton from "@/components/tutorial/scan"; import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import ProfilePicture from "@/components/profile-picture"; 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 { SwitchMiiInstructions } from "@/types";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -30,7 +34,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
include: { include: {
user: { user: {
select: { select: {
username: true, name: true,
}, },
}, },
_count: { _count: {
@ -44,28 +48,38 @@ 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 username = `@${mii.user.username}`; 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 Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.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: username, creator: name,
openGraph: { openGraph: {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
publishedTime: mii.createdAt.toISOString(), publishedTime: mii.createdAt.toISOString(),
authors: 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 Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }], images: [
creator: username, {
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
creator: mii.user.name!,
}, },
alternates: { alternates: {
canonical: `/mii/${mii.id}`, canonical: `/mii/${mii.id}`,
@ -85,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
@ -110,51 +123,104 @@ export default async function MiiPage({ params }: Props) {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="max-w-5xl w-full flex flex-col gap-4"> <div className="max-w-5xl w-full flex flex-col gap-4">
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1"> <div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2"> <div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */} {/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center"> <div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer <ImageViewer
src={`/mii/${mii.id}/image?type=mii`} src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={200} width={250}
height={200} height={250}
className="drop-shadow-lg hover:scale-105 transition-transform" className="drop-shadow-lg hover:scale-105 transition-transform w-full max-h-96 object-contain"
/> />
</div> </div>
{/* QR Code */} {/* QR Code */}
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> {mii.platform === "THREE_DS" && (
<ImageViewer <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
src={`/mii/${mii.id}/image?type=qr-code`} <ImageViewer
alt="mii qr code" src={`/mii/${mii.id}/image?type=qr-code`}
width={128} alt="mii qr code"
height={128} width={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all" height={128}
/> className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
</div> />
</div>
)}
<hr className="w-full border-t-2 border-t-amber-400" /> <hr className="w-full border-t-2 border-t-amber-400" />
{/* Mii Info */} {/* Mii Info */}
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1"> {mii.platform === "THREE_DS" && (
<li> <ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li>
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
{mii.allowedCopying !== null && (
<li> <li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox cursor-auto!" /> Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li> </li>
)} <li>
</ul> From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox cursor-auto!" />
</li>
</ul>
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div
className={`tooltip mt-1! ${
mii.platform === "THREE_DS" ? "bg-sky-400! border-sky-400! before:border-b-sky-400!" : "bg-red-400! border-red-400! before:border-b-red-400!"
}`}
>
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-13 text-3xl border-2 shadow-sm ${
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
</div>
<div
className={`rounded-xl flex justify-center items-center size-13 text-3xl border-2 shadow-sm ${
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</div>
</div>
{/* Mii Gender */} {/* Mii Gender */}
<div className="grid grid-cols-2 gap-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="flex gap-1">
<div <div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${ className={`tooltip mt-1! ${
mii.gender === "MALE"
? "bg-blue-400! border-blue-400! before:border-b-blue-400!"
: mii.gender === "FEMALE"
? "bg-pink-400! border-pink-400! before:border-b-pink-400!"
: "bg-purple-400! border-purple-400! before:border-b-purple-400!"
}`}
>
{mii.gender === "MALE" ? "Male" : mii.gender === "FEMALE" ? "Female" : "Nonbinary"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300" mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
}`} }`}
> >
@ -162,12 +228,22 @@ export default async function MiiPage({ params }: Props) {
</div> </div>
<div <div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${ className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "FEMALE" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300" mii.gender === "FEMALE" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300"
}`} }`}
> >
<Icon icon="foundation:female" className="text-pink-400" /> <Icon icon="foundation:female" className="text-pink-400" />
</div> </div>
{mii.platform !== "THREE_DS" && (
<div
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
mii.gender === "NONBINARY" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</div>
)}
</div> </div>
</div> </div>
@ -215,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" />
@ -230,8 +306,11 @@ export default async function MiiPage({ params }: Props) {
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</Link> </Link>
<ScanTutorialButton /> {mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
</div> </div>
{/* Instructions */}
{mii.platform === "SWITCH" && <MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} />}
</div> </div>
</div> </div>
@ -269,7 +348,7 @@ export default async function MiiPage({ params }: Props) {
))} ))}
</div> </div>
) : ( ) : (
<p className="indent-8 text-black/50">There is nothing here...</p> <p className="indent-7.5 text-black/50">There is nothing here...</p>
)} )}
</div> </div>
</div> </div>

View file

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

View file

@ -7,8 +7,8 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import Countdown from "@/components/countdown"; import Countdown from "@/components/countdown";
import MiiList from "@/components/mii-list"; import MiiList from "@/components/mii/list";
import Skeleton from "@/components/mii-list/skeleton"; import Skeleton from "@/components/mii/list/skeleton";
interface Props { interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@ -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

@ -5,8 +5,8 @@ import { Suspense } from "react";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import ProfileInformation from "@/components/profile-information"; import ProfileInformation from "@/components/profile-information";
import MiiList from "@/components/mii-list"; import MiiList from "@/components/mii/list";
import Skeleton from "@/components/mii-list/skeleton"; import Skeleton from "@/components/mii/list/skeleton";
interface Props { interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@ -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

@ -5,8 +5,8 @@ import { Suspense } from "react";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import ProfileInformation from "@/components/profile-information"; import ProfileInformation from "@/components/profile-information";
import Skeleton from "@/components/mii-list/skeleton"; import Skeleton from "@/components/mii/list/skeleton";
import MiiList from "@/components/mii-list"; import MiiList from "@/components/mii/list";
interface Props { interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;

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

@ -24,7 +24,7 @@ export default function ControlCenter() {
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
name="submit" id="submit"
type="checkbox" type="checkbox"
className="checkbox size-6!" className="checkbox size-6!"
placeholder="Enter banner text" placeholder="Enter banner text"

View file

@ -30,7 +30,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
<div className="flex justify-center items-center gap-2"> <div className="flex justify-center items-center gap-2">
<input <input
type="checkbox" type="checkbox"
name="agreement" id="agreement"
disabled={hasExpired} disabled={hasExpired}
checked={isChecked} checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)} onChange={(e) => setIsChecked(e.target.checked)}

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

@ -12,14 +12,16 @@ interface Props {
} }
export default function Carousel({ images, className }: Props) { export default function Carousel({ images, className }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel(); const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]); const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
useEffect(() => { useEffect(() => {
if (!emblaApi) return; if (!emblaApi) return;
emblaApi.reInit();
setScrollSnaps(emblaApi.scrollSnapList()); setScrollSnaps(emblaApi.scrollSnapList());
setSelectedIndex(0);
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap())); emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [images, emblaApi]); }, [images, emblaApi]);
@ -74,20 +76,20 @@ export default function Carousel({ images, className }: Props) {
> >
<Icon icon="ic:round-chevron-right" /> <Icon icon="ic:round-chevron-right" />
</button> </button>
<div className="flex justify-center p-2 gap-2 absolute right-0">
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
/>
))}
</div>
</> </>
)} )}
<div className="flex justify-center p-2 gap-2 absolute right-0">
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
/>
))}
</div>
</div> </div>
); );
} }

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

@ -0,0 +1,37 @@
import { ChangeEvent } from "react";
import { MiiGender } from "@prisma/client";
import { SwitchMiiInstructions } from "@/types";
interface Props {
data: SwitchMiiInstructions["datingPreferences"];
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
}
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
return (
<div className="flex flex-col gap-1.5">
{DATING_PREFERENCES.map((gender) => {
const genderEnum = gender.toUpperCase() as MiiGender;
return (
<div key={gender} className="flex gap-1.5">
<input
type="checkbox"
id={gender}
className="checkbox"
checked={data.includes(genderEnum)}
{...(typeof window !== "undefined" && onChecked
? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) }
: { readOnly: true })}
/>
<label htmlFor={gender} className="text-sm select-none">
{gender}
</label>
</div>
);
})}
</div>
);
}

View file

@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import LikeButton from "./like-button"; import LikeButton from "../like-button";
import SubmitButton from "./submit-button"; import SubmitButton from "../submit-button";
interface Props { interface Props {
miiId: number; miiId: number;

View file

@ -0,0 +1,257 @@
import React from "react";
import DatingPreferencesViewer from "./dating-preferences";
import VoiceViewer from "./voice-viewer";
import PersonalityViewer from "./personality-viewer";
import { SwitchMiiInstructions } from "@/types";
import { Icon } from "@iconify/react";
import { COLORS } from "@/lib/switch";
interface Props {
instructions: Partial<SwitchMiiInstructions>;
}
interface SectionProps {
name: string;
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
children?: React.ReactNode;
isSubSection?: boolean;
}
const ORDINAL_SUFFIXES: Record<string, string> = {
one: "st",
two: "nd",
few: "rd",
other: "th",
};
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1;
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
}
function ColorPosition({ color }: { color: number }) {
if (!color) return null;
if (color <= 7) {
return (
<>
Color menu on left, <GridPosition index={color} cols={1} />
</>
);
}
if (color >= 108) {
return (
<>
Outside color menu, <GridPosition index={color - 108} cols={2} />
</>
);
}
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} />
</span>
);
}
interface TableCellProps {
label: string;
children: React.ReactNode;
}
function TableCell({ label, children }: TableCellProps) {
return (
<tr className={"border-b border-orange-300/50 last:border-0"}>
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
<td className={"py-0.5 text-amber-950"}>{children}</td>
</tr>
);
}
function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object" || !instructions) return null;
const type = "type" in instructions ? instructions.type : undefined;
const color = "color" in instructions ? instructions.color : undefined;
const height = "height" in instructions ? instructions.height : undefined;
const distance = "distance" in instructions ? instructions.distance : undefined;
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
const size = "size" in instructions ? instructions.size : undefined;
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return (
<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>
<table className="w-full">
<tbody>
{type && (
<TableCell label="Type">
<GridPosition index={type} />
</TableCell>
)}
{color && (
<TableCell label="Color">
<ColorPosition color={color} />
</TableCell>
)}
{height && <TableCell label="Height">{height}</TableCell>}
{distance && <TableCell label="Distance">{distance}</TableCell>}
{rotation && <TableCell label="Rotation">{rotation}</TableCell>}
{size && <TableCell label="Size">{size}</TableCell>}
{stretch && <TableCell label="Stretch">{stretch}</TableCell>}
{children}
</tbody>
</table>
</div>
);
}
export default function MiiInstructions({ instructions }: Props) {
if (Object.keys(instructions).length === 0) return null;
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, datingPreferences, voice, personality } = instructions;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<Icon icon="fa7-solid:list" />
Instructions
</h2>
{head && <Section name="Head" instructions={head}></Section>}
{hair && (
<Section name="Hair" instructions={hair}>
{hair.setType && (
<TableCell label="Set Type">
<GridPosition index={hair.setType} />
</TableCell>
)}
{hair.bangsType && (
<TableCell label="Bangs Type">
<GridPosition index={hair.bangsType} />
</TableCell>
)}
{hair.backType && (
<TableCell label="Back Type">
<GridPosition index={hair.backType} />
</TableCell>
)}
{hair.subColor && (
<TableCell label="Sub Color">
<ColorPosition color={hair.subColor} />
</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>
)}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && (
<Section name="Eyes" instructions={eyes}>
<Section isSubSection name="Main" instructions={eyes.main} />
<Section isSubSection name="Eyelashes Top" instructions={eyes.eyelashesTop} />
<Section isSubSection name="Eyelashes Bottom" instructions={eyes.eyelashesBottom} />
<Section isSubSection name="Eyelid Top" instructions={eyes.eyelidTop} />
<Section isSubSection name="Eyelid Bottom" instructions={eyes.eyelidBottom} />
<Section isSubSection name="Eyeliner" instructions={eyes.eyeliner} />
<Section isSubSection name="Pupil" instructions={eyes.pupil} />
</Section>
)}
{nose && <Section name="Nose" instructions={nose}></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>}
{glasses && (
<Section name="Glasses" instructions={glasses}>
{glasses.ringColor && (
<TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} />
</TableCell>
)}
{glasses.shadesColor && (
<TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} />
</TableCell>
)}
</Section>
)}
{other && (
<Section name="Other" instructions={other}>
<Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} />
<Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} />
<Section isSubSection name="Beard" instructions={other.beard} />
<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="Mole" instructions={other.mole} />
<Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} />
<Section isSubSection name="Blush" instructions={other.blush} />
</Section>
)}
{(height || weight || datingPreferences || voice || personality) && (
<div className="pl-3 text-sm border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
{height && (
<div className="flex mb-1">
<label htmlFor="height" className="w-16">
Height
</label>
<input id="height" type="range" min={0} max={100} step={1} disabled value={height} />
</div>
)}
{weight && (
<div className="flex">
<label htmlFor="weight" className="w-16">
Weight
</label>
<input id="weight" type="range" min={0} max={100} step={1} disabled value={weight} />
</div>
)}
{datingPreferences && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
<div className="w-min">
<DatingPreferencesViewer data={datingPreferences} />
</div>
</div>
)}
{voice && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<div className="w-min">
<VoiceViewer data={voice} />
</div>
</div>
)}
{personality && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
<div className="w-min">
<PersonalityViewer data={personality} />
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -4,8 +4,9 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { MiiGender } from "@prisma/client"; import { MiiGender, MiiPlatform } from "@prisma/client";
import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter"; import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select"; import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters"; import OtherFilters from "./other-filters";
@ -16,9 +17,10 @@ export default function FilterMenu() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const rawTags = searchParams.get("tags") || ""; const rawTags = searchParams.get("tags") || "";
const rawExclude = searchParams.get("exclude") || ""; const rawExclude = searchParams.get("exclude") || "";
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false; const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo( const tags = useMemo(
@ -61,11 +63,12 @@ export default function FilterMenu() {
// Count all active filters // Count all active filters
useEffect(() => { useEffect(() => {
let count = tags.length + exclude.length; let count = tags.length + exclude.length;
if (platform) count++;
if (gender) count++; if (gender) count++;
if (allowCopying) count++; if (allowCopying) count++;
setFilterCount(count); setFilterCount(count);
}, [tags, exclude, gender, allowCopying]); }, [tags, exclude, platform, gender, allowCopying]);
return ( return (
<div className="relative"> <div className="relative">
@ -84,6 +87,20 @@ export default function FilterMenu() {
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div> <div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="grow border-zinc-300" />
</div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Tags Include</span> <span>Tags Include</span>
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
@ -97,19 +114,16 @@ export default function FilterMenu() {
</div> </div>
<TagFilter isExclude /> <TagFilter isExclude />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1"> {platform !== "SWITCH" && (
<hr className="grow border-zinc-300" /> <>
<span>Gender</span> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> <span>Other</span>
<GenderSelect /> <hr className="grow border-zinc-300" />
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1"> <OtherFilters />
<hr className="grow border-zinc-300" /> </>
<span>Other</span> )}
<hr className="grow border-zinc-300" />
</div>
<OtherFilters />
</div> </div>
)} )}
</div> </div>

View file

@ -3,7 +3,7 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { MiiGender } from "@prisma/client"; import { MiiGender, MiiPlatform } from "@prisma/client";
export default function GenderSelect() { export default function GenderSelect() {
const router = useRouter(); const router = useRouter();
@ -11,6 +11,7 @@ export default function GenderSelect() {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null); const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleClick = (gender: MiiGender) => { const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender; const filter = selected === gender ? null : gender;
@ -31,26 +32,44 @@ export default function GenderSelect() {
}; };
return ( return (
<div className="grid grid-cols-2 gap-0.5"> <div className="flex gap-0.5 w-fit">
<button <button
onClick={() => handleClick("MALE")} onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis" aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${ data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
<Icon icon="foundation:male" className="text-blue-400" /> <Icon icon="foundation:male" className="text-blue-400" />
</button> </button>
<button <button
onClick={() => handleClick("FEMALE")} onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis" aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${ data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
<Icon icon="foundation:female" className="text-pink-400" /> <Icon icon="foundation:female" className="text-pink-400" />
</button> </button>
{platform !== "THREE_DS" && (
<button
onClick={() => handleClick("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
)}
</div> </div>
); );
} }

View file

@ -1,6 +1,7 @@
import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { Prisma } from "@prisma/client"; import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import crypto from "crypto"; import crypto from "crypto";
@ -11,9 +12,9 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import SortSelect from "./sort-select"; import SortSelect from "./sort-select";
import Carousel from "../carousel"; import Carousel from "../../carousel";
import LikeButton from "../like-button"; import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii"; import DeleteMiiButton from "../delete-mii-button";
import Pagination from "./pagination"; import Pagination from "./pagination";
import FilterMenu from "./filter-menu"; import FilterMenu from "./filter-menu";
@ -25,16 +26,15 @@ 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>;
const { q: query, sort, tags, exclude, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
// My Likes page // 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 },
@ -52,6 +52,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
// Tag filtering // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender // Gender
...(gender && { gender: { equals: gender } }), ...(gender && { gender: { equals: gender } }),
// Allow Copying // Allow Copying
@ -67,10 +69,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
user: { user: {
select: { select: {
id: true, id: true,
username: true, name: true,
}, },
}, },
}), }),
platform: true,
name: true, name: true,
imageCount: true, imageCount: true,
tags: true, tags: true,
@ -143,7 +146,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
[totalCount, filteredCount, list] = await Promise.all([ [totalCount, filteredCount, list] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }), prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }), prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]); ]);
} }
@ -156,7 +165,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return ( return (
<div className="w-full"> <div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-[56rem]:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{totalCount == filteredCount ? ( {totalCount == filteredCount ? (
<> <>
@ -183,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`,
`/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) => (
@ -210,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

@ -32,7 +32,7 @@ export default function OtherFilters() {
<label htmlFor="allowCopying" className="text-sm"> <label htmlFor="allowCopying" className="text-sm">
Allow Copying Allow Copying
</label> </label>
<input type="checkbox" name="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} /> <input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
</div> </div>
); );
} }

View file

@ -0,0 +1,58 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import { MiiPlatform } from "@prisma/client";
export default function PlatformSelect() {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
const handleClick = (platform: MiiPlatform) => {
const filter = selected === platform ? null : platform;
setSelected(filter);
const params = new URLSearchParams(searchParams);
if (filter) {
params.set("platform", filter);
} else {
params.delete("platform");
}
startTransition(() => {
router.push(`?${params.toString()}`);
});
};
return (
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("THREE_DS")}
aria-label="Filter for 3DS Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")}
aria-label="Filter for Switch Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</button>
</div>
);
}

View file

@ -2,7 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../tag-selector"; import TagSelector from "../../tag-selector";
interface Props { interface Props {
isExclude?: boolean; isExclude?: boolean;

View file

@ -0,0 +1,49 @@
"use client";
import { SwitchMiiInstructions } from "@/types";
interface Props {
data: SwitchMiiInstructions["personality"];
onClick?: (key: string, i: number) => void;
}
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
{ label: "Movement", left: "Slow", right: "Quick" },
{ label: "Speech", left: "Polite", right: "Honest" },
{ label: "Energy", left: "Flat", right: "Varied" },
{ label: "Thinking", left: "Serious", right: "Chill" },
{ label: "Overall", left: "Normal", right: "Quirky" },
];
export default function PersonalityViewer({ data, onClick }: Props) {
return (
<div className="flex flex-col gap-1.5 mb-3">
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
const key = label.toLowerCase() as keyof typeof data;
return (
<div key={label} className="flex justify-center items-center gap-2">
<span className="text-sm font-semibold w-24 shrink-0">{label}</span>
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
<div className="flex gap-0.5">
{Array.from({ length: 6 }).map((_, i) => {
const colors = ["bg-green-400", "bg-green-300", "bg-teal-200", "bg-orange-200", "bg-orange-300", "bg-orange-400"];
return (
<button
key={i}
type="button"
onClick={() => {
if (onClick) onClick(key, i);
}}
className={`size-7 rounded-lg transition-opacity duration-100 border-orange-500
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
></button>
);
})}
</div>
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { SwitchMiiInstructions } from "@/types";
import { ChangeEvent } from "react";
interface Props {
data: SwitchMiiInstructions["voice"];
onClick?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
onClickTone?: (i: number) => void;
}
const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"];
export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
return (
<div className="flex flex-col gap-1">
{VOICE_SETTINGS.map((label) => (
<div key={label} className="flex gap-3">
<label htmlFor={label} className="text-sm w-14">
{label}
</label>
<input
type="range"
name={label}
className="grow"
min={0}
max={100}
step={1}
value={data[label as keyof typeof data]}
disabled={!onClick}
onChange={(e) => {
if (onClick) onClick(e, label);
}}
/>
</div>
))}
<div className="flex gap-3">
<label htmlFor="delivery" className="text-sm w-14">
Tone
</label>
<div className="grid grid-cols-6 gap-1 grow">
{Array.from({ length: 6 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
if (onClickTone) onClickTone(i);
}}
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClick ? "hover:bg-orange-300 cursor-pointer" : ""}`}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
);
}

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

@ -39,7 +39,7 @@ export default function DeleteAccount() {
return ( return (
<> <>
<button name="deletion" onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"> <button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
Delete Account Delete Account
</button> </button>

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 });
}), }),
); );
@ -147,7 +147,7 @@ export default function EditForm({ mii, likes }: Props) {
Name Name
</label> </label>
<input <input
name="name" id="name"
type="text" type="text"
className="pill input w-full col-span-2" className="pill input w-full col-span-2"
minLength={2} minLength={2}

View file

@ -2,22 +2,27 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone"; import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import qrcode from "qrcode-generator"; import qrcode from "qrcode-generator";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import { convertQrCode } from "@/lib/qr-codes"; import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii"; import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
import { SwitchMiiInstructions } from "@/types";
import TagSelector from "../tag-selector"; import TagSelector from "../tag-selector";
import ImageList from "./image-list"; import ImageList from "./image-list";
import PortraitUpload from "./portrait-upload";
import QrUpload from "./qr-upload"; import QrUpload from "./qr-upload";
import QrScanner from "./qr-scanner"; import QrScanner from "./qr-scanner";
import SubmitTutorialButton from "../tutorial/submit"; import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit";
import MiiEditor from "./mii-editor";
import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
import LikeButton from "../like-button"; import LikeButton from "../like-button";
import Carousel from "../carousel"; import Carousel from "../carousel";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
@ -35,16 +40,61 @@ export default function SubmitForm() {
); );
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>(); const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>(); const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]); const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE");
const instructions = useRef<SwitchMiiInstructions>({
head: { type: null, skinColor: null },
hair: {
setType: null,
bangsType: null,
backType: null,
color: null,
subColor: null,
subColor2: null,
style: null,
isFlipped: false,
},
eyebrows: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyes: {
main: { type: null, color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelashesTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelashesBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidTop: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyelidBottom: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
eyeliner: { type: null, color: null },
pupil: { type: null, height: null, distance: null, rotation: null, size: null, stretch: null },
},
nose: { type: null, height: null, size: null },
lips: { type: null, color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
ears: { type: null, height: null, size: null },
glasses: { type: null, ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
other: {
wrinkles1: { type: null, height: null, distance: null, size: null, stretch: null },
wrinkles2: { type: null, height: null, distance: null, size: null, stretch: null },
beard: { type: null, color: null },
moustache: { type: null, color: null, height: null, isFlipped: false, size: null, stretch: null },
goatee: { type: null, color: null },
mole: { type: null, color: null, height: null, distance: null, size: null },
eyeShadow: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
blush: { type: null, color: null, height: null, distance: null, size: null, stretch: null },
},
height: null,
weight: null,
datingPreferences: [],
voice: { speed: null, pitch: null, depth: null, delivery: null, tone: null },
personality: { movement: null, speech: null, energy: null, thinking: null, overall: null },
});
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate before sending request // Validate before sending request
const nameValidation = nameSchema.safeParse(name); const nameValidation = nameSchema.safeParse(name);
@ -60,15 +110,36 @@ export default function SubmitForm() {
// Send request to server // Send request to server
const formData = new FormData(); const formData = new FormData();
formData.append("platform", platform);
formData.append("name", name); formData.append("name", name);
formData.append("tags", JSON.stringify(tags)); formData.append("tags", JSON.stringify(tags));
formData.append("description", description); formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => { files.forEach((file, index) => {
// image1, image2, etc. // image1, image2, etc.
formData.append(`image${index + 1}`, file); formData.append(`image${index + 1}`, file);
}); });
if (platform === "THREE_DS") {
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
} else if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!);
if (!response.ok) {
setError("Failed to check Mii portrait. Did you upload one?");
return;
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
setError("Invalid image file returned");
return;
}
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
formData.append("instructions", JSON.stringify(instructions.current));
}
const response = await fetch("/api/submit", { const response = await fetch("/api/submit", {
method: "POST", method: "POST",
body: formData, body: formData,
@ -84,7 +155,7 @@ export default function SubmitForm() {
}; };
useEffect(() => { useEffect(() => {
if (qrBytesRaw.length == 0) return; if (platform === "SWITCH" || qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw); const qrBytes = new Uint8Array(qrBytesRaw);
const preview = async () => { const preview = async () => {
@ -96,38 +167,43 @@ export default function SubmitForm() {
return; return;
} }
// Convert QR code to JS // Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii };
try { try {
conversion = convertQrCode(qrBytes); conversion = convertQrCode(qrBytes);
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : String(error)); setError(error instanceof Error ? error.message : String(error));
return; return;
} }
// Generate a new QR code for aesthetic reasons
try { try {
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes); const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L"); const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte"); generatedCode.addData(byteString, "Byte");
generatedCode.make(); generatedCode.make();
setGeneratedQrCodeUrl(generatedCode.createDataURL()); setGeneratedQrCodeUri(generatedCode.createDataURL());
} catch { } catch {
setError("Failed to get and/or generate Mii images"); setError("Failed to regenerate QR code");
} }
}; };
preview(); preview();
}, [qrBytesRaw]); }, [qrBytesRaw, platform]);
return ( return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel images={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} /> <Carousel
images={[
miiPortraitUri ?? "/loading.svg",
...(platform === "THREE_DS" ? [generatedQrCodeUri ?? "/loading.svg"] : []),
...files.map((file) => URL.createObjectURL(file)),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}> <h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -162,12 +238,53 @@ export default function SubmitForm() {
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Platform select */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Platform
</label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
{/* Animated indicator */}
{/* TODO: maybe change width as part of animation? */}
<div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
}`}
></div>
{/* Switch button */}
<button
type="button"
onClick={() => setPlatform("SWITCH")}
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch
</button>
{/* 3DS button */}
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS
</button>
</div>
</div>
{/* Name */}
<div className="w-full grid grid-cols-3 items-center"> <div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold"> <label htmlFor="name" className="font-semibold">
Name Name
</label> </label>
<input <input
name="name" id="name"
type="text" type="text"
className="pill input w-full col-span-2" className="pill input w-full col-span-2"
minLength={2} minLength={2}
@ -185,11 +302,13 @@ export default function SubmitForm() {
<TagSelector tags={tags} setTags={setTags} showTagLimit /> <TagSelector tags={tags} setTags={setTags} showTagLimit />
</div> </div>
{/* Description */}
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">
<label htmlFor="reason-note" className="font-semibold py-2"> <label htmlFor="description" className="font-semibold py-2">
Description Description
</label> </label>
<textarea <textarea
id="description"
rows={5} rows={5}
maxLength={256} maxLength={256}
placeholder="(optional) Type a description..." placeholder="(optional) Type a description..."
@ -199,29 +318,107 @@ export default function SubmitForm() {
/> />
</div> </div>
{/* Separator */} {/* Gender (switch only) */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
<hr className="grow border-zinc-300" /> <label htmlFor="gender" className="font-semibold py-2">
<span>QR Code</span> Gender
<hr className="grow border-zinc-300" /> </label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
data-tooltip="Male"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
type="button"
onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis"
data-tooltip="Female"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
<button
type="button"
onClick={() => setGender("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip="Nonbinary"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
</div>
</div> </div>
<div className="flex flex-col items-center gap-2"> {/* (Switch Only) Mii Portrait */}
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
<span>or</span> {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="grow border-zinc-300" />
</div>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2"> <div className="flex flex-col items-center gap-2">
<Icon icon="mdi:camera" fontSize={20} /> <PortraitUpload setImage={setMiiPortraitUri} />
Use your camera <SwitchSubmitTutorialButton />
</button> </div>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<SubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div> </div>
{/* Separator */} {/* (3DS only) QR code scanning */}
<div className={`${platform === "THREE_DS" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>QR Code</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
</div>
{/* (Switch only) Mii instructions */}
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>Mii Instructions</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton />
<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>
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Custom images</span> <span>Custom images</span>

View file

@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { COLORS } from "@/lib/switch";
interface Props {
disabled?: boolean;
color: number;
setColor: (color: number) => void;
}
export default function ColorPicker({ disabled, color, setColor }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button
type="button"
onClick={() => {
if (isOpen) {
close();
} else {
setIsOpen(true);
}
}}
disabled={disabled}
className={`w-full flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
>
<Icon icon={"material-symbols:palette"} className="text-xl" />
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
</button>
{isOpen && (
<div
className={`absolute inset-0 w-122 p-4 bg-orange-100 rounded-lg transition-transform duration-500
flex items-center ${isVisible ? "opacity-100" : "opacity-0"}`}
style={{
transition: isVisible
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
}}
>
<div className="mr-8 flex flex-col gap-0.5">
{COLORS.slice(0, 8).map((c, i) => (
<button
type="button"
key={i}
onClick={() => setColor(i)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
// stagger by column then row for a wave effect
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<div className="grid grid-cols-10 gap-0.5">
{COLORS.slice(8, 108).map((c, i) => (
<button
type="button"
key={i + 8}
onClick={() => setColor(i + 8)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<button
type="button"
onClick={close}
className="absolute h-full w-16 top-0 right-0 cursor-pointer transition-transform hover:scale-115 active:scale-90"
>
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
</button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,80 @@
import { SwitchMiiInstructions } from "@/types";
import React, { useState } from "react";
import { Icon } from "@iconify/react";
import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair";
import EyebrowsTab from "./tabs/eyebrows";
import EyesTab from "./tabs/eyes";
import NoseTab from "./tabs/nose";
import LipsTab from "./tabs/lips";
import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
export const TAB_ICONS: Record<Tab, string> = {
head: "mingcute:head-fill",
hair: "mingcute:hair-fill",
eyebrows: "material-symbols:eyebrow",
eyes: "mdi:eye",
nose: "mingcute:nose-fill",
lips: "material-symbols-light:lips",
ears: "ion:ear",
glasses: "solar:glasses-bold",
other: "mdi:sparkles",
misc: "material-symbols:settings",
};
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
head: HeadTab,
hair: HairTab,
eyebrows: EyebrowsTab,
eyes: EyesTab,
nose: NoseTab,
lips: LipsTab,
ears: EarsTab,
glasses: GlassesTab,
other: OtherTab,
misc: MiscTab,
};
export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head");
return (
<>
<div className="w-full aspect-video flex bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
<div className="w-9 h-full flex flex-col">
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`size-9 flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
>
{/* ml because of border on left causing icons to look miscentered */}
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
</button>
))}
</div>
{/* Keep all tabs loaded to avoid flickering */}
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
const TabComponent = TAB_COMPONENTS[t];
return (
<div key={t} className={t === tab ? "grow flex" : "hidden"}>
<TabComponent instructions={instructions} />
</div>
);
})}
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show more