Compare commits

..

23 commits

Author SHA1 Message Date
5528c80516 fix: close button failing to be responsive on admin banners 2025-12-26 18:50:20 +00:00
0ab5d57f67 docs: better dev instructions and add .prettierrc 2025-12-26 18:50:04 +00:00
a13ac674be fix: share buttons webkit issues and formatted description changes 2025-12-20 20:29:56 +00:00
7a6eb389d9 feat: tutorial improvements
- refactored
- preload images
- also changed robots.txt
2025-12-20 20:16:18 +00:00
c9a10ee70b chore: update packages 2025-12-14 12:52:30 +00:00
2b6c1e84a2 chore: update packages
emergency update for CVE-2025-66478
2025-12-03 18:40:47 +00:00
cb5cd8e692 feat: button to close admin banners 2025-11-26 21:53:01 +00:00
065d8a641a feat: discord server 2025-11-23 21:26:19 +00:00
67bd6a4297 fix: send 404 when no miis are found in search API route
also remove redundant line in README
2025-11-16 21:27:24 +00:00
196f9d4640 feat: public search and mii data API routes
- also with an API reference that is not done
2025-11-16 21:23:04 +00:00
20ac1ea280 fix: disable pagination when ahead of last page 2025-11-12 19:55:43 +00:00
4d0e6188ec fix: redirect to page 1 when changing filters 2025-11-12 19:54:56 +00:00
6386e8a652 feat: more lax requirements for custom images 2025-11-12 19:51:18 +00:00
46a168b56c feat: profile about me sections 2025-11-10 19:20:33 +00:00
df1cdc67e9 feat: regenerate metadata images button in admin panel 2025-11-01 14:25:37 +00:00
9918ae8b37 feat: improve description 2025-10-31 15:29:12 +00:00
de63677650 feat: improve tag selector 2025-10-31 15:26:32 +00:00
a09b3cb56d feat: improve mii metadata images 2025-10-31 14:39:39 +00:00
69cf02e018 feat: fancy internal links in mii descriptions 2025-10-29 22:48:30 +00:00
347fa4824e feat: use name instead of username for mii page 2025-10-29 20:21:38 +00:00
6ffb85c49c style: improve descriptions' whitespace 2025-10-29 19:59:52 +00:00
f0df04d47c chore: update packages 2025-10-29 19:46:33 +00:00
b806f2c958 feat: add alt to images for SEO
SEO PART 18457
2025-09-15 22:18:42 +01:00
100 changed files with 2849 additions and 3357 deletions

4
.prettierrc Normal file
View file

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

129
API.md Normal file
View file

@ -0,0 +1,129 @@
# 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,8 +2,6 @@
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. 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 ## Getting started
To get the project up and running locally, follow these steps: To get the project up and running locally, follow these steps:
@ -14,7 +12,7 @@ $ cd tomodachi-share
$ pnpm install $ pnpm install
``` ```
Prisma types are generated automatically post-install, which is quite convenient. However, sometimes you might need to: Prisma types are generated automatically, however, sometimes you might need to:
```bash ```bash
# Generate Prisma client types # Generate Prisma client types
@ -25,6 +23,12 @@ $ pnpm prisma migrate dev
$ pnpm prisma generate $ 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 ## 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`: 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`:
@ -53,7 +57,13 @@ services:
- 6379:6379 - 6379:6379
``` ```
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. 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.
For the `AUTH_SECRET`, run the following in the command line: For the `AUTH_SECRET`, run the following in the command line:
@ -61,7 +71,10 @@ For the `AUTH_SECRET`, run the following in the command line:
$ pnpx auth secret $ pnpx auth secret
``` ```
Now, let's get the Discord and GitHub authentication set up. > [!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.
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`. 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,7 +25,9 @@
- 🌎 Browse and add Miis from other players - 🌎 Browse and add Miis from other players
- 🏝️ Build your perfect island by finding the perfect residents - 🏝️ Build your perfect island by finding the perfect residents
### <a href="/DEVELOPMENT.MD">Development Instructions</a> ### <a href="/DEVELOPMENT.md">Development Instructions</a>
### <a href="/API.md">API Reference</a>
--- ---

View file

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

@ -1,9 +0,0 @@
-- 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

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "accessKey" VARCHAR(7);

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/tutorial/step1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View file

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

View file

@ -0,0 +1,41 @@
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

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

View file

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

View file

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

View file

@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
} }
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center"> <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> <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"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Please create a username</span> <span>Please create a username</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<UsernameForm /> <UsernameForm />

View file

@ -32,7 +32,7 @@ body {
} }
.pill { .pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md; @apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
} }
.button { .button {
@ -64,7 +64,6 @@ body {
@apply block; @apply block;
} }
/* Tooltips */
[data-tooltip] { [data-tooltip] {
@apply relative z-10; @apply relative z-10;
} }
@ -82,24 +81,7 @@ body {
@apply opacity-100 scale-100; @apply opacity-100 scale-100;
} }
/* Fallback Tooltips */ /* Scrollbar */
[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 */ /* Firefox */
* { * {
scrollbar-color: #ff8903 transparent; scrollbar-color: #ff8903 transparent;

View file

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

View file

@ -17,14 +17,14 @@ export default async function LoginPage() {
} }
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center"> <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> <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"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Choose your login method</span> <span>Choose your login method</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<LoginButtons /> <LoginButtons />

View file

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

View file

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

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="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"> <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> <h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p> <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)); const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="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"> <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"> <h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE" {activePunishment.type === "PERM_EXILE"
@ -78,9 +78,9 @@ export default async function ExiledPage() {
</p> </p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Violating Items</span> <span>Violating Items</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col gap-2 p-4"> <div className="flex flex-col gap-2 p-4">
@ -95,7 +95,9 @@ export default async function ExiledPage() {
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex"> <div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} /> <Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4"> <div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p> <p className="text-xl font-bold line-clamp-1" title={"hello"}>
{mii.mii.name}
</p>
<p className="text-sm"> <p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason} <span className="font-bold">Reason:</span> {mii.reason}
</p> </p>

View file

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

View file

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

View file

@ -37,7 +37,7 @@ export default async function SubmitPage() {
if (!value) if (!value)
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="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"> <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> <h2 className="text-5xl font-black">Sorry</h2>
<p className="mt-1">Submissions are disabled</p> <p className="mt-1">Submissions are disabled</p>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
@ -18,7 +18,7 @@ function RedirectBanner() {
if (from !== "old-domain") return null; if (from !== "old-domain") return null;
return ( return (
<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"> <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">
<Icon icon="humbleicons:link" className="text-2xl min-w-6" /> <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span> <span>We have moved URLs, welcome to tomodachishare.com!</span>
</div> </div>
@ -27,14 +27,40 @@ function RedirectBanner() {
export default function AdminBanner() { export default function AdminBanner() {
const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher); 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 ( return (
<> <>
{data && data.message && ( {data && data.message && shouldShow && (
<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"> <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" /> <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span> <span>{data.message}</span>
</div> </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>
</div>
)} )}
<Suspense> <Suspense>
<RedirectBanner /> <RedirectBanner />

View file

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

View file

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

View file

@ -0,0 +1,86 @@
"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} disabled={hasExpired}
checked={isChecked} checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)} 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"}`}> <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree I Agree

View file

@ -238,7 +238,7 @@ export default function Punishments() {
rows={2} rows={2}
maxLength={256} maxLength={256}
placeholder="Type notes here for the punishment..." placeholder="Type notes here for the punishment..."
className="pill input !rounded-xl resize-none" className="pill input rounded-xl! resize-none"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
@ -249,7 +249,7 @@ export default function Punishments() {
rows={2} rows={2}
maxLength={256} maxLength={256}
placeholder="Type profile-related reasons here for the punishment..." 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} value={reasons}
onChange={(e) => setReasons(e.target.value)} onChange={(e) => setReasons(e.target.value)}
/> />
@ -273,7 +273,7 @@ export default function Punishments() {
value={newMii.reason} value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })} 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" /> <Icon icon="ic:baseline-plus" className="size-4" />
</button> </button>
</div> </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={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex"> <div className="flex">
{images.map((src, index) => ( {images.map((src, index) => (
<div key={index} className="flex-shrink-0 w-full"> <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} /> <ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-3/2 object-contain" images={images} />
</div> </div>
))} ))}
</div> </div>

View file

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

View file

@ -0,0 +1,83 @@
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()} {...getRootProps()}
onDragOver={() => setIsDraggingOver(true)} onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)} 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 h-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 size-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl" isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`} }`}
> >

View file

@ -28,10 +28,23 @@ export default function Footer() {
</span> </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 <a
href="https://github.com/trafficlunar/tomodachi-share" href="https://github.com/trafficlunar/tomodachi-share"
target="_blank" target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-center gap-1" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-end gap-1"
> >
<Icon icon="mdi:github" className="text-lg" /> <Icon icon="mdi:github" className="text-lg" />
Source Code Source Code

View file

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

View file

@ -9,7 +9,7 @@ export default function LoginButtons() {
<button <button
onClick={() => signIn("discord", { redirectTo: "/create-username" })} onClick={() => signIn("discord", { redirectTo: "/create-username" })}
aria-label="Login with Discord" aria-label="Login with Discord"
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500" className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
> >
<Icon icon="ic:baseline-discord" fontSize={32} /> <Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord Login with Discord
@ -17,7 +17,7 @@ export default function LoginButtons() {
<button <button
onClick={() => signIn("github", { redirectTo: "/create-username" })} onClick={() => signIn("github", { redirectTo: "/create-username" })}
aria-label="Login with GitHub" aria-label="Login with GitHub"
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white" className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
> >
<Icon icon="mdi:github" fontSize={32} /> <Icon icon="mdi:github" fontSize={32} />
Login with GitHub Login with GitHub

View file

@ -6,7 +6,7 @@ import { signOut } from "next-auth/react";
export default function LogoutButton() { export default function LogoutButton() {
return ( return (
<li title="Logout"> <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} /> <Icon icon="ic:round-logout" fontSize={24} />
</button> </button>
</li> </li>

View file

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

View file

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

View file

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

View file

@ -1,58 +0,0 @@
"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"> <div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */} {/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1"> <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> </div>
{/* Content */} {/* Content */}

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ export default async function ProfileOverview() {
<Link <Link
href={`/profile/${session?.user.id}`} href={`/profile/${session?.user.id}`}
aria-label="Go to profile" 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" data-tooltip="Your Profile"
> >
<Image <Image

View file

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

View file

@ -42,14 +42,14 @@ export default function DeleteAccount() {
<button <button
name="deletion" name="deletion"
onClick={() => setIsOpen(true)} 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 Delete Account
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ 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"> <button onClick={close} className="pill button">
Cancel Cancel
</button> </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> </div>
</div>, </div>,

View file

@ -9,18 +9,48 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture"; import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account"; import DeleteAccount from "./delete-account";
import z from "zod";
export default function ProfileSettings() { interface Props {
currentDescription: string | null | undefined;
}
export default function ProfileSettings({ currentDescription }: Props) {
const router = useRouter(); const router = useRouter();
const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined); const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined); const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days"); 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 handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName); const parsed = displayNameSchema.safeParse(displayName);
if (!parsed.success) { if (!parsed.success) {
@ -76,25 +106,54 @@ export default function ProfileSettings() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Account Info</span> <span>Account Info</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Profile Picture */} {/* Profile Picture */}
<ProfilePictureSettings /> <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 */} {/* Change Name */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div> <div className="col-span-3">
<label className="font-semibold">Change Display Name</label> <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> <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>
<div className="flex justify-end gap-1 h-min"> <div className="flex justify-end gap-1 h-min col-span-2">
<input <input
type="text" type="text"
className="pill input w-full max-w-64" className="pill input flex-1"
placeholder="Type here..." placeholder="Type here..."
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@ -114,14 +173,14 @@ export default function ProfileSettings() {
</div> </div>
{/* Change Username */} {/* Change Username */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div> <div className="col-span-3">
<label className="font-semibold">Change Username</label> <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> <p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
</div> </div>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1 col-span-2">
<div className="relative w-full max-w-64"> <div className="relative flex-1">
<input <input
type="text" type="text"
className="pill input w-full indent-4" className="pill input w-full indent-4"
@ -152,9 +211,9 @@ export default function ProfileSettings() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Danger Zone</span> <span>Danger Zone</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Delete Account */} {/* Delete Account */}

View file

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

View file

@ -37,13 +37,13 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
return ( 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" /> <Icon icon="material-symbols:check-rounded" />
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<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 className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ 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() { export default function RandomLink() {
return ( 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} /> <Icon icon="mdi:dice-3" fontSize={28} />
</Link> </Link>
); );

View file

@ -67,7 +67,7 @@ export default function ReportMiiForm({ mii, likes }: Props) {
rows={3} rows={3}
maxLength={256} maxLength={256}
placeholder="Type notes here for the report..." 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} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />

View file

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

View file

@ -65,7 +65,7 @@ export default function ReportUserForm({ user }: Props) {
rows={3} rows={3}
maxLength={256} maxLength={256}
placeholder="Type notes here for the report..." 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} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />

View file

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

View file

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

View file

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

View file

@ -1,36 +0,0 @@
"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,11 +9,12 @@ import QrFinder from "./qr-finder";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
interface Props { interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>; setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
} }
export default function QrScanner({ setQrBytesRaw }: Props) { export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null); const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
@ -126,15 +127,10 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
}; };
}, [isOpen, permissionGranted, scanQRCode]); }, [isOpen, permissionGranted, scanQRCode]);
return ( if (!isOpen) return null;
<>
<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>
{isOpen && ( return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -163,7 +159,7 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
type="button" type="button"
aria-label="Select camera dropdown" aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })} {...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm" className="pill input w-full px-2! py-0.5! justify-between! text-sm"
> >
{selectedItem?.label || "Select a camera"} {selectedItem?.label || "Select a camera"}
@ -197,7 +193,7 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8"> <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-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> <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"> <button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
Request Permission Request Permission
</button> </button>
</div> </div>
@ -231,7 +227,5 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
</div> </div>
</div> </div>
</div> </div>
)}
</>
); );
} }

View file

@ -14,8 +14,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
const handleDrop = useCallback( const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => { (acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0]; acceptedFiles.forEach((file) => {
// Scan QR code // Scan QR code
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
@ -40,6 +39,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
image.src = event.target!.result as string; image.src = event.target!.result as string;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
});
}, },
[setQrBytesRaw] [setQrBytesRaw]
); );

View file

@ -1,19 +1,21 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useRef } from "react";
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface Props { interface Props {
tags: string[]; tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>; setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
} }
const tagRegex = /^[a-z0-9-_]*$/; const tagRegex = /^[a-z0-9-_]*$/;
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"]; const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
export default function TagSelector({ tags, setTags }: Props) { export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
const [inputValue, setInputValue] = useState<string>(""); const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] => const getFilteredItems = (): string[] =>
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item)); predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
@ -23,7 +25,7 @@ export default function TagSelector({ tags, setTags }: Props) {
const hasSelectedItems = tags.length > 0; const hasSelectedItems = tags.length > 0;
const addTag = (tag: string) => { const addTag = (tag: string) => {
if (!tags.includes(tag) && tags.length < 8) { if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
setTags([...tags, tag]); setTags([...tags, tag]);
} }
}; };
@ -32,7 +34,7 @@ export default function TagSelector({ tags, setTags }: Props) {
setTags(tags.filter((t) => t !== tag)); setTags(tags.filter((t) => t !== tag));
}; };
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({ const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
inputValue, inputValue,
items: filteredItems, items: filteredItems,
onInputValueChange: ({ inputValue }) => { onInputValueChange: ({ inputValue }) => {
@ -61,11 +63,20 @@ export default function TagSelector({ tags, setTags }: Props) {
} }
}; };
const handleContainerClick = () => {
if (!isMaxItemsSelected) {
inputRef.current?.focus();
openMenu();
}
};
return ( return (
<div className="col-span-2 relative">
<div <div
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${ className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
tags.length > 0 ? "!py-1.5" : "" tags.length > 0 ? "py-1.5! px-2!" : ""
}`} }`}
onClick={handleContainerClick}
> >
{/* Tags */} {/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full"> <div className="flex flex-wrap gap-1.5 w-full">
@ -89,23 +100,31 @@ export default function TagSelector({ tags, setTags }: Props) {
{/* Input */} {/* Input */}
<input <input
{...getInputProps({ {...getInputProps({
ref: inputRef,
onKeyDown: handleKeyDown, onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected, disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...", placeholder: tags.length > 0 ? "" : "Type or select a tag...",
maxLength: 20,
className: "w-full flex-1 outline-none placeholder:text-black/40", className: "w-full flex-1 outline-none placeholder:text-black/40",
})} })}
/> />
</div> </div>
{/* Control buttons */} {/* Control buttons */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{hasSelectedItems && ( {hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}> <button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" /> <Icon icon="mdi:close" />
</button> </button>
)} )}
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl"> <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" /> <Icon icon="mdi:chevron-down" />
</button> </button>
</div> </div>
@ -114,12 +133,12 @@ export default function TagSelector({ tags, setTags }: Props) {
{!isMaxItemsSelected && ( {!isMaxItemsSelected && (
<ul <ul
{...getMenuProps()} {...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 ${ 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" isOpen ? "block" : "hidden"
}`} }`}
> >
{isOpen && {filteredItems.map((item, index) => (
filteredItems.map((item, index) => (
<li <li
key={item} key={item}
{...getItemProps({ item, index })} {...getItemProps({ item, index })}
@ -128,7 +147,7 @@ export default function TagSelector({ tags, setTags }: Props) {
{item} {item}
</li> </li>
))} ))}
{isOpen && inputValue && !filteredItems.includes(inputValue) && ( {inputValue && !filteredItems.includes(inputValue) && (
<li <li
className="px-4 py-1 cursor-pointer text-sm bg-black/15" className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => { onClick={() => {
@ -142,5 +161,17 @@ export default function TagSelector({ tags, setTags }: Props) {
</ul> </ul>
)} )}
</div> </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>
)}
</div>
)}
</div>
); );
} }

View file

@ -1,104 +0,0 @@
"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

@ -1,131 +0,0 @@
"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

@ -0,0 +1,215 @@
"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

@ -1,59 +0,0 @@
"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

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

View file

@ -1,62 +0,0 @@
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

@ -0,0 +1,64 @@
"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

@ -1,104 +0,0 @@
"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

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

View file

@ -1,3 +1,4 @@
import { MiiGender } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny // profanity censoring bypasses the regex in some of these but I think it's funny
@ -26,7 +27,7 @@ export const tagsSchema = z
z z
.string() .string()
.min(2, { error: "Tags must be at least 2 characters long" }) .min(2, { error: "Tags must be at least 2 characters long" })
.max(64, { error: "Tags cannot be more than 20 characters long" }) .max(20, { error: "Tags cannot be more than 20 characters long" })
.regex(/^[a-z0-9-_]+$/, { .regex(/^[a-z0-9-_]+$/, {
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.", error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
}) })
@ -39,6 +40,36 @@ export const idSchema = z.coerce
.int({ error: "ID must be an integer" }) .int({ error: "ID must be an integer" })
.positive({ error: "ID must be valid" }); .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 // Account Info
export const usernameSchema = z export const usernameSchema = z
.string() .string()

10
src/types.d.ts vendored
View file

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