Compare commits

..

8 commits

Author SHA1 Message Date
07f3bb35d8 feat: 20f1c51f access key version 2025-10-29 17:18:18 +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
100 changed files with 3352 additions and 2844 deletions

View file

@ -1,4 +0,0 @@
{
"tabWidth": 2,
"useTabs": true
}

129
API.md
View file

@ -1,129 +0,0 @@
# TomodachiShare API Reference
Welcome to the TomodachiShare API Reference!
Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_).
## Public Endpoints
### **Search Miis**
`GET /api/search?q={query}`
Searches Miis by name, tags, and description.
#### **Query Parameters**
| Name | Type | Required | Description |
| ------ | ------ | -------- | ----------------------------------------------------------------- |
| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. |
| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. |
| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. |
| gender | string | No | Gender filter: `MALE` or `FEMALE`. |
| limit | number | No | Number of results per page (1-100). |
| page | number | No | Page number. Defaults to `1`. |
| seed | number | No | Seed used for `random` sorting to ensure unique results per page. |
#### **Examples**
```
https://tomodachishare.com/api/search?q=frieren
```
```
https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204
```
#### **Response**
Returns an array of Mii IDs:
```json
[1, 204, 295, 1024]
```
When no Miis are found:
```json
{ "error": "No Miis found!" }
```
---
### **Get Mii Image / QR Code / Metadata Image**
`GET /mii/{id}/image?type={type}`
Retrieves the Mii image, QR code, or metadata graphic.
#### **Path & Query Parameters**
| Name | Type | Required | Description |
| -------- | ------ | -------- | ------------------------------------- |
| **id** | number | **Yes** | The Miis ID. |
| **type** | string | **Yes** | One of: `mii`, `qr-code`, `metadata`. |
#### **Examples**
```
https://tomodachishare.com/mii/1/image?type=mii
```
```
https://tomodachishare.com/mii/2/image?type=qr-code
```
```
https://tomodachishare.com/mii/3/image?type=metadata
```
#### **Response**
Returns the image file.
---
### **Get Mii Data**
`GET /mii/{id}/data`
Fetches metadata for a specific Mii.
#### **Path Parameters**
| Name | Type | Required | Description |
| ------ | ------ | -------- | ------------- |
| **id** | number | **Yes** | The Miis ID. |
#### **Example**
```
https://tomodachishare.com/mii/1/data
```
#### **Response**
```json
{
"id": 1,
"name": "Frieren",
"imageCount": 3,
"tags": ["anime", "frieren"],
"description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!",
"firstName": "Frieren",
"lastName": "the Slayer",
"gender": "FEMALE",
"islandName": "Wuhu",
"allowedCopying": false,
"createdAt": "2025-05-04T12:29:41Z",
"user": {
"id": 1,
"username": "trafficlunar",
"name": "trafficlunar"
},
"likes": 29
}
```
## Protected Endpoints
_TODO_

View file

@ -2,6 +2,8 @@
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
Note: this project is intended to be used on Linux - in production and development.
## Getting started
To get the project up and running locally, follow these steps:
@ -12,7 +14,7 @@ $ cd tomodachi-share
$ pnpm install
```
Prisma types are generated automatically, however, sometimes you might need to:
Prisma types are generated automatically post-install, which is quite convenient. However, sometimes you might need to:
```bash
# Generate Prisma client types
@ -23,12 +25,6 @@ $ pnpm prisma migrate dev
$ pnpm prisma generate
```
I recommend opting out of Next.js' telemetry program but it is not a requirement.
```bash
$ pnpm exec next telemetry disable
```
## Environment variables
You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`:
@ -57,13 +53,7 @@ services:
- 6379:6379
```
After starting the docker applications, apply TomodachiShare's database schema migrations.
```bash
$ pnpm prisma migrate dev
```
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest of the variables.
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest.
For the `AUTH_SECRET`, run the following in the command line:
@ -71,10 +61,7 @@ For the `AUTH_SECRET`, run the following in the command line:
$ pnpx auth secret
```
> [!NOTE]
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `.env`
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
Now, let's get the Discord and GitHub authentication set up.
For Discord, create an application in the developer portal, go to 'OAuth2', copy in the Client ID and Secret into the respective variables and also add this as a redirect URL: `http://localhost:3000/api/auth/callback/discord`.

View file

@ -25,9 +25,7 @@
- 🌎 Browse and add Miis from other players
- 🏝️ Build your perfect island by finding the perfect residents
### <a href="/DEVELOPMENT.md">Development Instructions</a>
### <a href="/API.md">API Reference</a>
### <a href="/DEVELOPMENT.MD">Development Instructions</a>
---

View file

@ -1,60 +1,66 @@
{
"name": "tomodachi-share",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.24.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate",
"test": "vitest"
},
"dependencies": {
"@2toad/profanity": "^3.2.0",
"@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.1",
"bit-buffer": "^0.2.5",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19",
"downshift": "^9.0.13",
"embla-carousel-react": "^8.6.0",
"file-type": "^21.1.1",
"ioredis": "^5.8.2",
"jsqr": "^1.4.0",
"next": "16.0.10",
"next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0",
"satori": "^0.18.3",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"swr": "^2.3.7",
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.1.18",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34",
"eslint": "^9.39.2",
"eslint-config-next": "16.0.10",
"prisma": "^6.19.1",
"schema-dts": "^1.1.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^4.0.15"
}
"name": "tomodachi-share",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.14.0",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate",
"test": "vitest"
},
"dependencies": {
"@2toad/profanity": "^3.1.1",
"@auth/prisma-adapter": "2.10.0",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.16.1",
"bit-buffer": "^0.2.5",
"canvas-confetti": "^1.9.3",
"dayjs": "^1.11.18",
"downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0",
"file-type": "^21.0.0",
"ioredis": "^5.7.0",
"jsqr": "^1.4.0",
"next": "16.0.0-beta.0",
"next-auth": "5.0.0-beta.25",
"qrcode-generator": "^2.0.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0",
"satori": "^0.18.2",
"seedrandom": "^3.0.5",
"sharp": "^0.34.3",
"sjcl-with-all": "1.0.8",
"swr": "^2.3.6",
"zod": "^4.1.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@iconify/react": "^6.0.1",
"@tailwindcss/postcss": "^4.1.13",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.3.1",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34",
"eslint": "^9.35.0",
"eslint-config-next": "16.0.0-beta.0",
"prisma": "^6.16.1",
"schema-dts": "^1.1.5",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1"
}
}
}

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 @@
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "accessKey" VARCHAR(7);

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);

View file

@ -14,7 +14,6 @@ model User {
email String @unique
emailVerified DateTime?
image String?
description String? @db.VarChar(256)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -69,18 +68,21 @@ model Session {
}
model Mii {
id Int @id @default(autoincrement())
userId Int
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
id Int @id @default(autoincrement())
userId Int
firstName String
lastName String
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS)
accessKey String? @db.VarChar(7)
firstName String?
lastName String?
gender MiiGender?
islandName String
allowedCopying Boolean
islandName String?
allowedCopying Boolean?
createdAt DateTime @default(now())
@ -154,6 +156,11 @@ model Punishment {
@@map("punishments")
}
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender {
MALE
FEMALE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View file

@ -5,7 +5,6 @@ import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form";
import ControlCenter from "@/components/admin/control-center";
import RegenerateImagesButton from "@/components/admin/regenerate-images";
import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports";
@ -32,37 +31,36 @@ export default async function AdminPage() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Banners</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<BannerForm />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Control Center</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<ControlCenter />
<RegenerateImagesButton />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>User Management</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<UserManagement />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Reports</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<Reports />

View file

@ -1,41 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateMetadataImage } from "@/lib/images";
export async function PATCH() {
const session = await auth();
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 });
// Start processing in background
regenerateImages().catch(console.error);
return NextResponse.json({ success: true });
}
async function regenerateImages() {
// Get miis in batches to reduce memory usage
const BATCH_SIZE = 10;
const totalMiis = await prisma.mii.count();
let processed = 0;
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
const miis = await prisma.mii.findMany({
skip,
take: BATCH_SIZE,
include: { user: { select: { name: true } } },
});
// Process each batch sequentially to avoid overwhelming the server
for (const mii of miis) {
try {
await generateMetadataImage(mii, mii.user.name);
processed++;
} catch (error) {
console.error(`Failed to generate image for mii ${mii.id}:`, error);
}
}
}
}

View file

@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { profanity } from "@2toad/profanity";
import z from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { description } = await request.json();
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
const validation = z.string().trim().max(256).safeParse(description);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { description: profanity.censor(description) },
});
} catch (error) {
console.error("Failed to update description:", error);
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -44,6 +44,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
where: {
id: miiId,
},
include: {
user: {
select: {
username: true,
},
},
},
});
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
@ -95,18 +102,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (images.length > 0) updateData.imageCount = images.length;
if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
const updatedMii = await prisma.mii.update({
await prisma.mii.update({
where: {
id: miiId,
},
data: updateData,
include: {
user: {
select: {
name: true,
},
},
},
});
// Only touch files if new images were uploaded
@ -136,7 +136,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
} else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image
await generateMetadataImage(updatedMii, updatedMii.user.name!);
try {
await generateMetadataImage(mii, mii.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
}
return rateLimit.sendResponse({ success: true });

View file

@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Prisma, ReportReason, ReportType } from "@prisma/client";
import { ReportReason, ReportType } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
import { MiiWithUsername } from "@/types";
const reportSchema = z.object({
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
@ -29,15 +30,7 @@ export async function POST(request: NextRequest) {
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { id, type, reason, notes } = parsed.data;
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}> | null = null;
let mii: MiiWithUsername | null = null;
// Check if the Mii or User exists
if (type === "mii") {

View file

@ -1,79 +0,0 @@
import { NextRequest } from "next/server";
import crypto from "crypto";
import seedrandom from "seedrandom";
import { searchSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
export async function GET(request: NextRequest) {
const rateLimit = new RateLimit(request, 24, "/api/search");
const check = await rateLimit.handle();
if (check) return check;
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
const where: Prisma.MiiWhereInput = {
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Gender
...(gender && { gender: { equals: gender } }),
};
const skip = (page - 1) * limit;
if (sort === "random") {
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
if (matchingIds.length === 0) return rateLimit.sendResponse({ error: "No Miis found!" }, 404);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// Convert to number[] array and return paginated results
return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id));
} else {
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
const list = await prisma.mii.findMany({
where,
orderBy,
select: { id: true },
skip,
take: limit,
});
return rateLimit.sendResponse(list.map((mii) => mii.id));
}
}

View file

@ -7,7 +7,7 @@ import sharp from "sharp";
import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity";
import { MiiGender } from "@prisma/client";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@ -21,95 +21,150 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z.object({
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(256).optional(),
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }),
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
});
const submitSchema = z
.object({
platform: z.enum(MiiPlatform).default("THREE_DS"),
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(256).optional(),
// Switch
accessKey: z
.string()
.length(7, { error: "Access key must be 7 characters in length" })
.regex(/^[a-zA-Z0-9]+$/, "Access key must be alphanumeric"),
gender: z.enum(MiiGender).default("MALE"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// QR code
qrBytesRaw: z
.array(z.number(), { error: "A QR code is required" })
.length(372, { error: "QR code size is not a valid Tomodachi Life QR code" })
.optional(),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
})
.refine(
(data) => {
// If platform is Switch, accessKey, gender, and miiPortraitImage must be present
if (data.platform === "SWITCH") {
return data.accessKey !== undefined && data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Access key, gender, and Mii portrait image is required for Switch",
path: ["accessKey", "gender", "miiPortraitImage"],
}
);
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 2);
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
// Parse data
// Parse tags and QR code as JSON
const formData = await request.formData();
let rawTags: string[];
let rawQrBytesRaw: string[]; // raw raw
let rawQrBytesRaw: string[] | undefined = undefined; // good variable name - raw raw; is undefined for zod to ignore it if platform is Switch
try {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch {
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400);
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
}
// Parse and check all submission info
const parsed = submitSchema.safeParse({
platform: formData.get("platform"),
name: formData.get("name"),
tags: rawTags,
description: formData.get("description"),
qrBytesRaw: rawQrBytesRaw,
accessKey: formData.get("accessKey"),
gender: formData.get("gender"),
miiPortraitImage: formData.get("miiPortraitImage"),
qrBytesRaw: rawQrBytesRaw ?? undefined,
image1: formData.get("image1"),
image2: formData.get("image2"),
image3: formData.get("image3"),
});
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data;
const data = parsed.data;
// Censor potential inappropriate words
const name = profanity.censor(uncensoredName);
const tags = uncensoredTags.map((t) => profanity.censor(t));
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
const name = profanity.censor(data.name);
const tags = data.tags.map((t) => profanity.censor(t));
const description = data.description && profanity.censor(data.description);
// Validate image files
const images: File[] = [];
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
for (const img of [data.image1, data.image2, data.image3]) {
if (!img) continue;
const imageValidation = await validateImage(img);
if (imageValidation.valid) {
images.push(img);
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
}
const qrBytes = new Uint8Array(qrBytesRaw);
// Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") {
const imageValidation = await validateImage(data.miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
// Convert QR code to JS
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
if (data.platform === "THREE_DS") {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
}
}
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
userId: Number(session.user.id),
platform: data.platform,
name,
tags,
description,
gender: data.gender ?? "MALE",
firstName: conversion.tomodachiLifeMii.firstName,
lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
// Access key only for Switch
accessKey: data.platform === "SWITCH" ? data.accessKey : null,
// Automatically detect certain information if on 3DS
...(data.platform === "THREE_DS" &&
conversion && {
firstName: conversion.tomodachiLifeMii.firstName,
lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
}),
},
});
@ -117,62 +172,74 @@ export async function POST(request: NextRequest) {
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii
let studioBuffer: Buffer;
try {
const studioUrl = conversion.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl);
let portraitBuffer: Buffer | undefined;
if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
// Download the image of the Mii (3DS)
if (data.platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl!);
if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
}
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
} else if (data.platform === "SWITCH") {
portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer());
}
const studioArrayBuffer = await studioResponse.arrayBuffer();
studioBuffer = Buffer.from(studioArrayBuffer);
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(fileLocation, webpBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download Mii image:", error);
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
console.error("Failed to download/store Mii portrait:", error);
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
if (data.platform === "THREE_DS") {
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
}
try {
// Compress and store
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(studioFileLocation, studioWebpBuffer);
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.name!);
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
}
// Compress and store user images
try {
await Promise.all(
images.map(async (image, index) => {
customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
@ -187,7 +254,7 @@ export async function POST(request: NextRequest) {
id: miiRecord.id,
},
data: {
imageCount: images.length,
imageCount: customImages.length,
},
});
} catch (error) {

View file

@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
}
return (
<div className="grow flex items-center justify-center">
<div className="flex-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" />
<hr className="flex-grow border-zinc-300" />
<span>Please create a username</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<UsernameForm />

View file

@ -32,7 +32,7 @@ body {
}
.pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md;
}
.button {
@ -64,6 +64,7 @@ body {
@apply block;
}
/* Tooltips */
[data-tooltip] {
@apply relative z-10;
}
@ -81,7 +82,24 @@ body {
@apply opacity-100 scale-100;
}
/* Scrollbar */
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-[999999];
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
/* Scrollbars */
/* Firefox */
* {
scrollbar-color: #ff8903 transparent;

View file

@ -92,7 +92,7 @@ export default function RootLayout({
<Providers>
<Header />
<AdminBanner />
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<main className="px-4 py-8 max-w-7xl w-full flex-grow flex flex-col">{children}</main>
<Footer />
</Providers>
</body>

View file

@ -17,14 +17,14 @@ export default async function LoginPage() {
}
return (
<div className="grow flex items-center justify-center">
<div className="flex-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 TomodachiShare!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Choose your login method</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<LoginButtons />

View file

@ -1,49 +0,0 @@
import { NextRequest } from "next/server";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/data");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const data = await prisma.mii.findUnique({
where: { id: miiId },
select: {
id: true,
name: true,
_count: {
select: {
likedBy: true,
},
},
imageCount: true,
tags: true,
description: true,
firstName: true,
lastName: true,
gender: true,
islandName: true,
allowedCopying: true,
createdAt: true,
user: { select: { id: true, username: true, name: true } },
},
});
if (!data) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
const { _count, ...rest } = data;
return rateLimit.sendResponse({
...rest,
likes: _count.likedBy,
});
}

View file

@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
@ -9,6 +8,7 @@ import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
import { MiiWithUsername } from "@/types";
const searchParamsSchema = z.object({
type: z
@ -37,15 +37,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
let mii: MiiWithUsername | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
@ -55,7 +47,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
include: {
user: {
select: {
name: true,
username: true,
},
},
},
@ -74,13 +66,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.name!);
if (error) {
return rateLimit.sendResponse({ error }, status);
try {
buffer = await generateMetadataImage(mii, mii.user.username!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}

View file

@ -12,9 +12,8 @@ import LikeButton from "@/components/like-button";
import ImageViewer from "@/components/image-viewer";
import DeleteMiiButton from "@/components/delete-mii";
import ShareMiiButton from "@/components/share-mii-button";
import ScanTutorialButton from "@/components/tutorial/scan";
import ProfilePicture from "@/components/profile-picture";
import Description from "@/components/description";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
interface Props {
params: Promise<{ id: string }>;
@ -49,22 +48,22 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: username,
openGraph: {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
publishedTime: mii.createdAt.toISOString(),
authors: username,
},
twitter: {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
creator: username,
},
alternates: {
@ -84,7 +83,6 @@ export default async function MiiPage({ params }: Props) {
include: {
user: {
select: {
name: true,
username: true,
},
},
@ -112,45 +110,102 @@ export default async function MiiPage({ params }: Props) {
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50">
<ImageViewer
src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={200}
height={200}
className="drop-shadow-lg hover:scale-105 transition-transform"
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
/>
</div>
{/* QR Code */}
{/* QR Code/Access key */}
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
height={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
{mii.platform === "THREE_DS" ? (
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
height={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
) : (
<h1 className="font-bold text-3xl">{mii.accessKey}</h1>
)}
</div>
<hr className="w-full border-t-2 border-t-amber-400" />
{/* Mii Info */}
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<li>
Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li>
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox cursor-auto!" />
</li>
</ul>
{mii.platform === "THREE_DS" && (
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
<li>
Name:{" "}
<span className="text-right font-medium">
{mii.firstName} {mii.lastName}
</span>
</li>
<li>
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" />
</li>
</ul>
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div
className={`tooltip !mt-1 ${
mii.platform === "THREE_DS"
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
}`}
>
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</div>
</div>
{/* Mii Gender */}
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="flex-grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
<div
className={`tooltip !mt-1 ${
mii.gender === "MALE"
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
}`}
>
{mii.gender === "MALE" ? "Male" : "Female"}
</div>
<div
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
@ -174,7 +229,7 @@ export default async function MiiPage({ params }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
{/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
<h1 className="text-4xl font-extrabold break-words text-amber-700">{mii.name}</h1>
{/* Like button */}
<LikeButton
likes={mii._count.likedBy ?? 0}
@ -196,7 +251,7 @@ export default async function MiiPage({ params }: Props) {
{/* Author and Created date */}
<div className="mt-2">
<Link href={`/profile/${mii.userId}`} className="text-lg">
By <span className="font-bold">{mii.user.name}</span>
By: <span className="font-bold">@{mii.user.username}</span>
</Link>
<h4 className="text-sm">
Created:{" "}
@ -214,7 +269,7 @@ export default async function MiiPage({ params }: Props) {
</div>
{/* Description */}
{mii.description && <Description text={mii.description} className="ml-2" />}
{mii.description && <p className="text-sm mt-2 ml-2 bg-white/50 p-3 rounded-lg border border-orange-200">{mii.description}</p>}
</div>
{/* Buttons */}
@ -234,7 +289,7 @@ export default async function MiiPage({ params }: Props) {
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</Link>
<ScanTutorialButton />
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
</div>
</div>
</div>
@ -251,7 +306,7 @@ export default async function MiiPage({ params }: Props) {
{images.map((src, index) => (
<div
key={index}
className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
className="relative aspect-[3/2] rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
>
<Image
src={src}
@ -266,7 +321,7 @@ export default async function MiiPage({ params }: Props) {
alt="mii screenshot"
width={256}
height={170}
className="aspect-3/2 w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
className="aspect-[3/2] w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
images={images}
/>
</div>

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function NotFound() {
return (
<div className="grow flex items-center justify-center">
<div className="flex-grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>

View file

@ -51,7 +51,7 @@ export default async function ExiledPage() {
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return (
<div className="grow flex items-center justify-center">
<div className="flex-grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
<h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE"
@ -78,9 +78,9 @@ export default async function ExiledPage() {
</p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Violating Items</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col gap-2 p-4">
@ -95,9 +95,7 @@ export default async function ExiledPage() {
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1" title={"hello"}>
{mii.mii.name}
</p>
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>

View file

@ -2,7 +2,6 @@ import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ProfileSettings from "@/components/profile-settings";
import ProfileInformation from "@/components/profile-information";
@ -21,12 +20,10 @@ export default async function ProfileSettingsPage() {
if (!session) redirect("/login");
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } });
return (
<div>
<ProfileInformation page="settings" />
<ProfileSettings currentDescription={user?.description} />
<ProfileSettings />
</div>
);
}

View file

@ -16,6 +16,7 @@ export default function robots(): MetadataRoute.Robots {
"/report/mii/*",
"/report/user/*",
"/admin",
"/_next/image",
],
},
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,

View file

@ -37,7 +37,7 @@ export default async function SubmitPage() {
if (!value)
return (
<div className="grow flex items-center justify-center">
<div className="flex-grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-5xl font-black">Sorry</h2>
<p className="mt-1">Submissions are disabled</p>

View file

@ -1,7 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { Suspense } from "react";
import useSWR from "swr";
import { Icon } from "@iconify/react";
@ -18,7 +18,7 @@ function RedirectBanner() {
if (from !== "old-domain") return null;
return (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span>
</div>
@ -27,39 +27,13 @@ function RedirectBanner() {
export default function AdminBanner() {
const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
const [shouldShow, setShouldShow] = useState(true);
useEffect(() => {
if (!data?.message) return;
// Check if the current banner text was closed by the user
const closedBanner = window.localStorage.getItem("closedBanner");
setShouldShow(data.message !== closedBanner);
}, [data]);
const handleClose = () => {
if (!data) return;
// Close banner and remember it
window.localStorage.setItem("closedBanner", data.message);
setShouldShow(false);
};
return (
<>
{data && data.message && shouldShow && (
<div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
<div className="flex gap-2 h-full items-center w-fit">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span>
</div>
<button
onClick={handleClose}
className="min-sm:absolute right-2 cursor-pointer p-1.5"
>
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
{data && data.message && (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span>
</div>
)}
<Suspense>

View file

@ -26,7 +26,7 @@ export default function ControlCenter() {
<input
name="submit"
type="checkbox"
className="checkbox size-6!"
className="checkbox !size-6"
placeholder="Enter banner text"
checked={canSubmit}
onChange={(e) => setCanSubmit(e.target.checked)}

View file

@ -55,7 +55,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${

View file

@ -1,86 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
export default function RegenerateImagesButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
close();
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
Regenerate all Mii metadata images
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Regenerate Images</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} />
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -34,7 +34,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
disabled={hasExpired}
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
className={`checkbox ${hasExpired && "text-zinc-600 !bg-zinc-100 !border-zinc-300"}`}
/>
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree

View file

@ -238,7 +238,7 @@ export default function Punishments() {
rows={2}
maxLength={256}
placeholder="Type notes here for the punishment..."
className="pill input rounded-xl! resize-none"
className="pill input !rounded-xl resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
@ -249,7 +249,7 @@ export default function Punishments() {
rows={2}
maxLength={256}
placeholder="Type profile-related reasons here for the punishment..."
className="pill input rounded-xl! resize-none"
className="pill input !rounded-xl resize-none"
value={reasons}
onChange={(e) => setReasons(e.target.value)}
/>
@ -273,7 +273,7 @@ export default function Punishments() {
value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
/>
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square !p-2.5">
<Icon icon="ic:baseline-plus" className="size-4" />
</button>
</div>

View file

@ -43,8 +43,8 @@ export default function Carousel({ images, className }: Props) {
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex">
{images.map((src, index) => (
<div key={index} className="shrink-0 w-full">
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-3/2 object-contain" images={images} />
<div key={index} className="flex-shrink-0 w-full">
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-[3/2] object-contain" images={images} />
</div>
))}
</div>

View file

@ -69,7 +69,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -107,7 +107,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
</div>
</div>
</div>,

View file

@ -1,83 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
interface Props {
text: string;
className?: string;
}
export default function Description({ text, className }: Props) {
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{/* Adds fancy formatting when linking to other pages on the site */}
{(() => {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
// Match both mii and profile links
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
const parts = text.split(regex);
return parts.map(async (part, index) => {
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
if (miiMatch) {
const id = Number(miiMatch[1]);
const linkedMii = await prisma.mii.findUnique({
where: {
id,
},
});
if (!linkedMii) return;
return (
<Link
key={index}
href={`/mii/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs"
>
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" />
{linkedMii.name}
</Link>
);
}
if (profileMatch) {
const id = Number(profileMatch[1]);
const linkedProfile = await prisma.user.findUnique({
where: {
id,
},
});
if (!linkedProfile) return;
return (
<Link
key={index}
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"
>
<ProfilePicture
src={linkedProfile.image || "/guest.webp"}
width={24}
height={24}
className="bg-white rounded-lg border-r border-orange-400"
/>
{linkedProfile.name}
</Link>
);
}
// Regular text
return <span key={index}>{part}</span>;
});
})()}
</p>
);
}

View file

@ -32,7 +32,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
{...getRootProps()}
onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none h-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`}
>

View file

@ -28,23 +28,10 @@ export default function Footer() {
</span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon icon="ic:baseline-discord" className="text-lg" />
Discord
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a
href="https://github.com/trafficlunar/tomodachi-share"
target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-end gap-1"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-center gap-1"
>
<Icon icon="mdi:github" className="text-lg" />
Source Code

View file

@ -77,7 +77,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -99,7 +99,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
<div className="flex h-full items-center">
{imagesMap.map((image, index) => (
<div key={index} className="shrink-0 w-full">
<div key={index} className="flex-shrink-0 w-full">
<Image
src={image}
alt={alt}

View file

@ -9,7 +9,7 @@ export default function LoginButtons() {
<button
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
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"
>
<Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord
@ -17,7 +17,7 @@ export default function LoginButtons() {
<button
onClick={() => signIn("github", { redirectTo: "/create-username" })}
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"
>
<Icon icon="mdi:github" fontSize={32} />
Login with GitHub

View file

@ -6,7 +6,7 @@ import { signOut } from "next-auth/react";
export default function LogoutButton() {
return (
<li title="Logout">
<button onClick={() => signOut()} aria-label="Log Out" className="pill button p-0! aspect-square h-full" data-tooltip="Log Out">
<button onClick={() => signOut()} aria-label="Log Out" className="pill button !p-0 aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} />
</button>
</li>

View file

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

View file

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

View file

@ -1,17 +1,16 @@
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import { z } from "zod";
import crypto from "crypto";
import seedrandom from "seedrandom";
import { searchSchema } from "@/lib/schemas";
import { querySchema } from "@/lib/schemas";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import GenderSelect from "./gender-select";
import TagFilter from "./tag-filter";
import FilterMenu from "./filter-menu";
import SortSelect from "./sort-select";
import Carousel from "../carousel";
import LikeButton from "../like-button";
@ -24,13 +23,44 @@ interface Props {
inLikesPage?: boolean; // Self-explanatory
}
const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
tags: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
const session = await auth();
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -52,6 +82,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Profiles
@ -69,6 +101,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
@ -95,7 +128,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
if (sort === "random") {
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const randomSeed = seed || Math.floor(Math.random() * 1_000_000_000);
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
@ -154,7 +187,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-[56rem]:flex-col">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
{totalCount == filteredCount ? (
<>
@ -171,9 +204,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
)}
</div>
<div className="flex items-center justify-end gap-2 w-full min-[56rem]:max-w-2/3 max-[56rem]:justify-center max-sm:flex-col">
<GenderSelect />
<TagFilter />
<div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
</div>
@ -187,7 +219,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel
images={[
`/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>

View file

@ -44,8 +44,8 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
}`}
>
<Icon icon="stash:chevron-double-left" />
@ -57,7 +57,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
className={`pill !bg-orange-100 !p-0.5 aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"}`}
>
<Icon icon="stash:chevron-left" />
</Link>
@ -70,7 +70,7 @@ export default function Pagination({ lastPage }: Props) {
href={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
className={`pill !p-0 w-8 h-8 text-center !rounded-md ${number == page ? "!bg-orange-400" : "!bg-orange-100 hover:!bg-orange-400"}`}
>
{number}
</Link>
@ -79,12 +79,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Next page */}
<Link
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
href={page === lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
aria-disabled={page === lastPage}
tabIndex={page === lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
}`}
>
<Icon icon="stash:chevron-right" />
@ -92,12 +92,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Go to last page */}
<Link
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
href={page === lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
aria-disabled={page === lastPage}
tabIndex={page === lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
}`}
>
<Icon icon="stash:chevron-double-right" />

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

@ -21,7 +21,7 @@ export default function Skeleton() {
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-3/2"></div>
<div className="aspect-[3/2]"></div>
</div>
{/* Content */}

View file

@ -23,7 +23,6 @@ export default function SortSelect() {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem);
if (selectedItem == "random") {
@ -39,7 +38,7 @@ export default function SortSelect() {
return (
<div className="relative w-fit">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap">
<span>Sort by </span>
{selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -36,8 +36,6 @@ export default function TagFilter() {
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) {
params.set("tags", stateTags);
} else {

View file

@ -5,7 +5,6 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
import Description from "./description";
interface Props {
userId?: number;
@ -35,7 +34,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
{/* User information */}
<div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
<h1 className="text-3xl font-extrabold break-words">{user.name}</h1>
{isAdmin && (
<div data-tooltip="Admin" className="text-orange-400">
<Icon icon="mdi:shield-moon" className="text-2xl" />
@ -47,9 +46,9 @@ export default async function ProfileInformation({ userId, page }: Props) {
</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 break-words">@{user?.username}</h2>
<div className="mt-3 text-sm flex gap-8">
<div className="mt-auto text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "}
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
@ -58,8 +57,6 @@ export default async function ProfileInformation({ userId, page }: Props) {
Liked <span className="font-bold">{likedMiis}</span> Miis
</h4>
</div>
{user.description && <Description text={user.description} className="max-h-32!" />}
</div>
</div>

View file

@ -10,7 +10,7 @@ export default async function ProfileOverview() {
<Link
href={`/profile/${session?.user.id}`}
aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64"
className="pill button !gap-2 !p-0 h-full max-w-64"
data-tooltip="Your Profile"
>
<Image

View file

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

View file

@ -42,14 +42,14 @@ export default function DeleteAccount() {
<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!"
className="pill button w-fit h-min ml-auto !bg-red-400 !border-red-500 hover:!bg-red-500"
>
Delete Account
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -79,7 +79,7 @@ export default function DeleteAccount() {
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
</div>
</div>
</div>,

View file

@ -9,48 +9,18 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account";
import z from "zod";
interface Props {
currentDescription: string | null | undefined;
}
export default function ProfileSettings({ currentDescription }: Props) {
export default function ProfileSettings() {
const router = useRouter();
const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days");
const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description);
if (!parsed.success) {
setDescriptionChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/about-me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
});
if (!response.ok) {
const { error } = await response.json();
setDescriptionChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName);
if (!parsed.success) {
@ -106,54 +76,25 @@ export default function ProfileSettings({ currentDescription }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Account Info</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
{/* Profile Picture */}
<ProfilePictureSettings />
{/* Description */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">About Me</label>
<p className="text-sm text-zinc-500">Write about yourself on your profile</p>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<div className="flex-1">
<textarea
rows={5}
maxLength={256}
placeholder="(optional) Type about yourself..."
className="pill input rounded-xl! resize-none text-sm w-full"
value={description || ""}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-xs text-zinc-400 mt-1 text-right">{(description || "").length}/256</p>
</div>
<SubmitDialogButton
title="Confirm About Me Change"
description="Are you sure? You can change it again later."
error={descriptionChangeError}
onSubmit={handleSubmitDescriptionChange}
/>
</div>
</div>
{/* Change Name */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<div>
<label className="font-semibold">Change Display 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>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<div className="flex justify-end gap-1 h-min">
<input
type="text"
className="pill input flex-1"
className="pill input w-full max-w-64"
placeholder="Type here..."
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
@ -173,14 +114,14 @@ export default function ProfileSettings({ currentDescription }: Props) {
</div>
{/* Change Username */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<div>
<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">
<div className="flex justify-end gap-1">
<div className="relative w-full max-w-64">
<input
type="text"
className="pill input w-full indent-4"
@ -211,9 +152,9 @@ export default function ProfileSettings({ currentDescription }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Danger Zone</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
{/* Delete Account */}

View file

@ -43,13 +43,13 @@ export default function ProfilePictureSettings() {
}, []);
return (
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<div className="grid grid-cols-2">
<div>
<label className="font-semibold">Profile Picture</label>
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p>
</div>
<div className="flex flex-col col-span-2">
<div className="flex flex-col">
<div className="flex justify-end">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-xs">
@ -74,7 +74,7 @@ export default function ProfilePictureSettings() {
data-tooltip="Delete Picture"
aria-label="Delete Picture"
onClick={() => setNewPicture(undefined)}
className="pill button aspect-square p-1! text-2xl bg-red-400! border-red-500!"
className="pill button aspect-square !p-1 text-2xl !bg-red-400 !border-red-500"
>
<Icon icon="mdi:trash-outline" />
</button>

View file

@ -37,13 +37,13 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 !p-1 text-2xl">
<Icon icon="material-symbols:check-rounded" />
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${

View file

@ -5,7 +5,7 @@ import { Icon } from "@iconify/react";
export default function RandomLink() {
return (
<Link href={"/random"} aria-label="Go to Random Link" className="pill button p-0! h-full aspect-square" data-tooltip="Go to a Random Mii">
<Link href={"/random"} aria-label="Go to Random Link" className="pill button !p-0 h-full aspect-square" data-tooltip="Go to a Random Mii">
<Icon icon="mdi:dice-3" fontSize={28} />
</Link>
);

View file

@ -67,7 +67,7 @@ export default function ReportMiiForm({ mii, likes }: Props) {
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
className="pill input !rounded-xl resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>

View file

@ -40,7 +40,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
type="button"
{...getToggleButtonProps()}
aria-label="Report reason dropdown"
className="pill input w-full gap-1 justify-between! text-nowrap"
className="pill input w-full gap-1 !justify-between text-nowrap"
>
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -65,7 +65,7 @@ export default function ReportUserForm({ user }: Props) {
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
className="pill input !rounded-xl resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>

View file

@ -67,7 +67,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -92,7 +92,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */}
<button
className="absolute! top-2.5 right-2.5 cursor-pointer"
className="!absolute top-2.5 right-2.5 cursor-pointer"
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
onClick={handleCopyUrl}
>
@ -118,9 +118,9 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>or</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
@ -139,7 +139,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Save button */}
<a
href={`/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
className="pill button !p-0 aspect-square cursor-pointer text-xl"
aria-label="Save Image"
data-tooltip="Save Image"
download={"hello.png"}
@ -149,7 +149,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */}
<button
className="pill button p-0! aspect-square size-11 cursor-pointer"
className="pill button !p-0 aspect-square cursor-pointer"
aria-label="Copy Image"
data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"}
onClick={handleCopyImage}

View file

@ -106,7 +106,7 @@ export default function EditForm({ mii, likes }: Props) {
return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]}
/>
@ -139,9 +139,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Info</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<div className="w-full grid grid-cols-3 items-center">
@ -164,7 +164,7 @@ export default function EditForm({ mii, likes }: Props) {
<label htmlFor="tags" className="font-semibold">
Tags
</label>
<TagSelector tags={tags} setTags={setTags} showTagLimit />
<TagSelector tags={tags} setTags={setTags} />
</div>
<div className="w-full grid grid-cols-3 items-start">
@ -172,10 +172,10 @@ export default function EditForm({ mii, likes }: Props) {
Description
</label>
<textarea
rows={5}
rows={3}
maxLength={256}
placeholder="(optional) Type a description..."
className="pill input rounded-xl! resize-none col-span-2 text-sm"
className="pill input !rounded-xl resize-none col-span-2"
value={description ?? ""}
onChange={(e) => setDescription(e.target.value)}
/>
@ -183,9 +183,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* 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" />
<hr className="flex-grow border-zinc-300" />
<span>Custom images</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<div className="max-w-md w-full self-center">

View file

@ -43,13 +43,13 @@ export default function ImageList({ files, setFiles }: Props) {
alt={file.name}
width={96}
height={96}
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/>
<div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
<button
onClick={() => handleDelete(index)}
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400"
>
Delete
</button>

View file

@ -7,6 +7,7 @@ import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react";
import qrcode from "qrcode-generator";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { convertQrCode } from "@/lib/qr-codes";
@ -15,15 +16,29 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
import TagSelector from "../tag-selector";
import ImageList from "./image-list";
import PortraitUpload from "./portrait-upload";
import QrUpload from "./qr-upload";
import QrScanner from "./qr-scanner";
import SubmitTutorialButton from "../tutorial/submit";
import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit";
import LikeButton from "../like-button";
import Carousel from "../carousel";
import SubmitButton from "../submit-button";
import Dropzone from "../dropzone";
export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [accessKey, setAccessKey] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback(
@ -34,17 +49,6 @@ export default function SubmitForm() {
[files.length]
);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
@ -60,15 +64,26 @@ export default function SubmitForm() {
// Send request to server
const formData = new FormData();
formData.append("platform", platform);
formData.append("name", name);
formData.append("tags", JSON.stringify(tags));
formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => {
// image1, image2, etc.
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!);
const blob = await response.blob();
formData.append("accessKey", accessKey);
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
}
const response = await fetch("/api/submit", {
method: "POST",
body: formData,
@ -84,6 +99,7 @@ export default function SubmitForm() {
};
useEffect(() => {
if (platform !== "THREE_DS") return;
if (qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw);
@ -100,34 +116,35 @@ export default function SubmitForm() {
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try {
conversion = convertQrCode(qrBytes);
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
return;
}
// Generate a new QR code for aesthetic reasons
try {
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
setGeneratedQrCodeUrl(generatedCode.createDataURL());
setGeneratedQrCodeUri(generatedCode.createDataURL());
} catch {
setError("Failed to get and/or generate Mii images");
setError("Failed to regenerate QR code");
}
};
preview();
}, [qrBytesRaw]);
}, [qrBytesRaw, platform]);
return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-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))]} />
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -157,11 +174,51 @@ export default function SubmitForm() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
<span>Info</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
{/* Platform select */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Platform
</label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
{/* Animated indicator */}
<div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
}`}
></div>
{/* Switch button */}
<button
type="button"
onClick={() => setPlatform("SWITCH")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
Switch
</button>
{/* 3DS button */}
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "!text-black"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
3DS
</button>
</div>
</div>
{/* Name */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="name" className="font-semibold">
Name
@ -182,53 +239,115 @@ export default function SubmitForm() {
<label htmlFor="tags" className="font-semibold">
Tags
</label>
<TagSelector tags={tags} setTags={setTags} showTagLimit />
<TagSelector tags={tags} setTags={setTags} />
</div>
{/* Description */}
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="reason-note" className="font-semibold py-2">
<label htmlFor="description" className="font-semibold py-2">
Description
</label>
<textarea
rows={5}
name="description"
rows={3}
maxLength={256}
placeholder="(optional) Type a description..."
className="pill input rounded-xl! resize-none col-span-2 text-sm"
className="pill input !rounded-xl resize-none col-span-2"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* 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>QR Code</span>
<hr className="grow border-zinc-300" />
</div>
{platform === "SWITCH" && (
<>
{/* Access Key */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="accessKey" className="font-semibold">
Access Key <SwitchSubmitTutorialButton />
</label>
<input
name="accessKey"
type="text"
className="pill input w-full col-span-2"
minLength={7}
maxLength={7}
placeholder="Type your mii's access key here..."
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
{/* Gender */}
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<button
type="button"
onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>
</div>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<SubmitTutorialButton />
{/* Mii Portrait */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="flex-grow border-zinc-300" />
</div>
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
<div className="flex flex-col items-center gap-2">
<PortraitUpload setImage={setMiiPortraitUri} />
</div>
</>
)}
{/* Separator */}
{/* QR code selector */}
{platform === "THREE_DS" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
</>
)}
{/* Custom images selector */}
<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="flex-grow border-zinc-300" />
<span>Custom images</span>
<hr className="grow border-zinc-300" />
<hr className="flex-grow border-zinc-300" />
</div>
<div className="max-w-md w-full self-center flex flex-col items-center">
<div className="max-w-md w-full self-center">
<Dropzone onDrop={handleDrop}>
<p className="text-center text-sm">
Drag and drop your images here
@ -236,8 +355,6 @@ export default function SubmitForm() {
or click to open
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />

View file

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

View file

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

View file

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

View file

@ -1,21 +1,19 @@
"use client";
import React, { useState, useRef } from "react";
import React, { useState } from "react";
import { useCombobox } from "downshift";
import { Icon } from "@iconify/react";
interface Props {
tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
}
const tagRegex = /^[a-z0-9-_]*$/;
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
export default function TagSelector({ tags, setTags }: Props) {
const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] =>
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
@ -25,7 +23,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
const hasSelectedItems = tags.length > 0;
const addTag = (tag: string) => {
if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
if (!tags.includes(tag) && tags.length < 8) {
setTags([...tags, tag]);
}
};
@ -34,7 +32,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
setTags(tags.filter((t) => t !== tag));
};
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
inputValue,
items: filteredItems,
onInputValueChange: ({ inputValue }) => {
@ -63,82 +61,65 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
}
};
const handleContainerClick = () => {
if (!isMaxItemsSelected) {
inputRef.current?.focus();
openMenu();
}
};
return (
<div className="col-span-2 relative">
<div
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
tags.length > 0 ? "py-1.5! px-2!" : ""
}`}
onClick={handleContainerClick}
>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full">
{tags.map((tag) => (
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
{tag}
<button
type="button"
aria-label="Delete Tag"
className="text-black cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
>
<Icon icon="mdi:close" className="text-xs" />
</button>
</span>
))}
{/* Input */}
<input
{...getInputProps({
ref: inputRef,
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
maxLength: 20,
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
<div
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
tags.length > 0 ? "!py-1.5" : ""
}`}
>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full">
{tags.map((tag) => (
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
{tag}
<button
type="button"
aria-label="Delete Tag"
className="text-black cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
>
<Icon icon="mdi:close" className="text-xs" />
</button>
)}
</span>
))}
<button
type="button"
aria-label="Toggle Tag Dropdown"
{...getToggleButtonProps()}
disabled={isMaxItemsSelected}
className="text-black cursor-pointer text-xl disabled:text-black/35"
>
<Icon icon="mdi:chevron-down" />
{/* Input */}
<input
{...getInputProps({
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1">
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
</button>
</div>
)}
{/* Dropdown menu */}
{!isMaxItemsSelected && (
<ul
{...getMenuProps()}
onClick={(e) => e.stopPropagation()}
className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{filteredItems.map((item, index) => (
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
<Icon icon="mdi:chevron-down" />
</button>
</div>
{/* Dropdown menu */}
{!isMaxItemsSelected && (
<ul
{...getMenuProps()}
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
filteredItems.map((item, index) => (
<li
key={item}
{...getItemProps({ item, index })}
@ -147,30 +128,18 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
{item}
</li>
))}
{inputValue && !filteredItems.includes(inputValue) && (
<li
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => {
addTag(inputValue);
setInputValue("");
}}
>
Add &quot;{inputValue}&quot;
</li>
)}
</ul>
)}
</div>
{/* Tag limit message */}
{showTagLimit && (
<div className="mt-1.5 text-xs min-h-4">
{isMaxItemsSelected ? (
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
) : (
<span className="text-black/60">{tags.length}/8 tags</span>
{isOpen && inputValue && !filteredItems.includes(inputValue) && (
<li
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => {
addTag(inputValue);
setInputValue("");
}}
>
Add &quot;{inputValue}&quot;
</li>
)}
</div>
</ul>
)}
</div>
);

View file

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

View file

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

View file

@ -1,215 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide {
// step is never used, undefined is assumed as a step
type?: "start" | "step" | "finish";
text?: string;
imageSrc?: string;
}
interface Tutorial {
title: string;
thumbnail?: string;
hint?: string;
steps: Slide[];
}
interface Props {
tutorials: Tutorial[];
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map
const slides: Array<Slide & { tutorialTitle: string }> = [];
const startSlides: Record<string, number> = {};
tutorials.forEach((tutorial) => {
tutorial.steps.forEach((slide) => {
if (slide.type === "start") {
startSlides[tutorial.title] = slides.length;
}
slides.push({ ...slide, tutorialTitle: tutorial.title });
});
});
const currentSlide = slides[selectedIndex];
const isStartingPage = currentSlide?.type === "start";
useEffect(() => {
if (currentSlide.type !== "finish") return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [currentSlide]);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
const goToTutorial = (tutorialTitle: string) => {
if (!emblaApi) return;
const index = startSlides[tutorialTitle];
// Jump to next starting slide then transition to actual tutorial
emblaApi.scrollTo(index, true);
emblaApi.scrollTo(index + 1);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-120 transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{slides.map((slide, index) => (
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
{slide.type === "start" ? (
<>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
{tutorials.map((tutorial, tutorialIndex) => (
<button
key={tutorialIndex}
onClick={() => goToTutorial(tutorial.title)}
aria-label={tutorial.title + " tutorial"}
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={tutorial.thumbnail!}
alt="tutorial thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">{tutorial.title}</p>
{/* Set opacity to 0 to keep height the same with other tutorials */}
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
</button>
))}
</div>
</>
) : slide.type === "finish" ? (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
) : (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
<Image
src={slide.imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
loading="eager"
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
)}
</div>
))}
</div>
</div>
{/* Arrows */}
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
{/* Only show tutorial name on step slides */}
<span
className={`text-sm transition-opacity duration-300 ${
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
}`}
>
{currentSlide?.tutorialTitle}
</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import Image from "next/image";
import { Icon } from "@iconify/react";
import { useEffect } from "react";
import confetti from "canvas-confetti";
interface Props {
text?: string;
imageSrc?: string;
carouselIndex?: number;
finishIndex?: number;
}
export default function TutorialPage({ text, imageSrc, carouselIndex, finishIndex }: Props) {
useEffect(() => {
if (carouselIndex !== finishIndex || !carouselIndex || !finishIndex) return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [carouselIndex, finishIndex]);
return (
<div className="flex-shrink-0 flex flex-col w-full px-6">
{!finishIndex ? (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{text}</p>
<Image
src={imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
) : (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,62 @@
import Image from "next/image";
import { UseEmblaCarouselType } from "embla-carousel-react";
interface Props {
emblaApi: UseEmblaCarouselType[1] | undefined;
isSwitch?: boolean;
}
export default function StartingPage({ emblaApi, isSwitch }: Props) {
const goToTutorial = (index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index - 1, true);
emblaApi.scrollTo(index);
};
return (
<div className="flex-shrink-0 flex flex-col w-full px-6 py-6">
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
<button
onClick={() => goToTutorial(1)}
aria-label="Allow Copying Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
alt="Allow Copying thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Allow Copying</p>
<p className="text-[0.65rem] text-zinc-400">Suggested!</p>
</button>
<button
onClick={() => goToTutorial(10)}
aria-label="Create QR Code Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
alt="Creating QR code thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Create QR Code</p>
{/* Add placeholder to keep height the same */}
<p className="text-[0.65rem] opacity-0">placeholder</p>
</button>
</div>
</div>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'Mii List'", imageSrc: "/tutorial/allow-copying/step2.png" },
{ text: "3. Select and edit the Mii you wish to submit", imageSrc: "/tutorial/allow-copying/step3.png" },
{ text: "4. Click 'Other Settings' in the information screen", imageSrc: "/tutorial/allow-copying/step4.png" },
{ text: "5. Click on 'Don't Allow' under the 'Copying' text", imageSrc: "/tutorial/allow-copying/step5.png" },
{ text: "6. Press 'Allow'", imageSrc: "/tutorial/allow-copying/step6.png" },
{ text: "7. Confirm the edits to the Mii", imageSrc: "/tutorial/allow-copying/step7.png" },
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/create-qr-code/step2.png" },
{ text: "3. Press 'Create QR Code'", imageSrc: "/tutorial/create-qr-code/step3.png" },
{ text: "4. Select and press 'OK' on the Mii you wish to submit", imageSrc: "/tutorial/create-qr-code/step4.png" },
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>
);
}

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [128, 128];
const MIN_IMAGE_DIMENSIONS = [320, 240];
const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
@ -48,7 +48,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
) {
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080" };
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 320x240 and 1920x1080" };
}
// Check for inappropriate content
@ -121,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
);
};
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> {
export async function generateMetadataImage(mii: Mii, author: string): Promise<Buffer> {
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently
@ -146,8 +146,14 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} />
<div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img
src={miiImage}
width={248}
height={248}
tw="w-full h-full"
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }}
/>
</div>
{/* QR code */}
@ -162,29 +168,22 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
{mii.name}
</span>
{/* Tags */}
<div id="tags" tw="relative flex mt-1 w-full overflow-hidden">
<div tw="flex">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm shrink-0">
{tag}
</span>
))}
</div>
<div tw="absolute inset-0" style={{ position: "absolute", backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);" }}></div>
<div id="tags" tw="flex flex-wrap mt-1 w-full">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm">
{tag}
</span>
))}
</div>
{/* Author */}
<div tw="flex mt-2 text-sm w-1/2">
By{" "}
<span tw="ml-1.5 font-semibold overflow-hidden" style={{ textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{author}
</span>
<div tw="flex text-sm mt-2">
By: <span tw="ml-1.5 font-semibold">@{author}</span>
</div>
{/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={34} />
{/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare
@ -204,16 +203,11 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file
try {
// I tried using .webp here but the quality looked awful
// but it actually might be well-liked due to the hatred of .webp
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}
// I tried using .webp here but the quality looked awful
// but it actually might be well-liked due to the hatred of .webp
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
return { buffer };
return buffer;
}
//#endregion

View file

@ -1,4 +1,3 @@
import { MiiGender } from "@prisma/client";
import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny
@ -27,7 +26,7 @@ export const tagsSchema = z
z
.string()
.min(2, { error: "Tags must be at least 2 characters long" })
.max(20, { error: "Tags cannot be more than 20 characters long" })
.max(64, { error: "Tags cannot be more than 20 characters long" })
.regex(/^[a-z0-9-_]+$/, {
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
})
@ -40,36 +39,6 @@ export const idSchema = z.coerce
.int({ error: "ID must be an integer" })
.positive({ error: "ID must be valid" });
export const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
tags: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
// Account Info
export const usernameSchema = z
.string()

10
src/types.d.ts vendored
View file

@ -12,3 +12,13 @@ declare module "next-auth" {
username?: string;
}
}
type MiiWithUsername = Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}>;