Compare commits
7 commits
main
...
feat/livin
| Author | SHA1 | Date | |
|---|---|---|---|
| 76fecca011 | |||
| f9dd7a396c | |||
| 93e26b8937 | |||
| 43c67d75a9 | |||
| 90a6b741be | |||
| e1b269d99b | |||
| 20f1c51f0c |
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": true
|
||||
}
|
||||
129
API.md
|
|
@ -1,129 +0,0 @@
|
|||
# TomodachiShare API Reference
|
||||
|
||||
Welcome to the TomodachiShare API Reference!
|
||||
Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_).
|
||||
|
||||
## Public Endpoints
|
||||
|
||||
### **Search Miis**
|
||||
|
||||
`GET /api/search?q={query}`
|
||||
|
||||
Searches Miis by name, tags, and description.
|
||||
|
||||
#### **Query Parameters**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| ------ | ------ | -------- | ----------------------------------------------------------------- |
|
||||
| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. |
|
||||
| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. |
|
||||
| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. |
|
||||
| gender | string | No | Gender filter: `MALE` or `FEMALE`. |
|
||||
| limit | number | No | Number of results per page (1-100). |
|
||||
| page | number | No | Page number. Defaults to `1`. |
|
||||
| seed | number | No | Seed used for `random` sorting to ensure unique results per page. |
|
||||
|
||||
#### **Examples**
|
||||
|
||||
```
|
||||
https://tomodachishare.com/api/search?q=frieren
|
||||
```
|
||||
|
||||
```
|
||||
https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204
|
||||
```
|
||||
|
||||
#### **Response**
|
||||
|
||||
Returns an array of Mii IDs:
|
||||
|
||||
```json
|
||||
[1, 204, 295, 1024]
|
||||
```
|
||||
|
||||
When no Miis are found:
|
||||
|
||||
```json
|
||||
{ "error": "No Miis found!" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Get Mii Image / QR Code / Metadata Image**
|
||||
|
||||
`GET /mii/{id}/image?type={type}`
|
||||
|
||||
Retrieves the Mii image, QR code, or metadata graphic.
|
||||
|
||||
#### **Path & Query Parameters**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| -------- | ------ | -------- | ------------------------------------- |
|
||||
| **id** | number | **Yes** | The Mii’s 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 Mii’s 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_
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
|
||||
|
||||
Note: this project is intended to be used on Linux - in production and development.
|
||||
|
||||
## Getting started
|
||||
|
||||
To get the project up and running locally, follow these steps:
|
||||
|
|
@ -12,7 +14,7 @@ $ cd tomodachi-share
|
|||
$ pnpm install
|
||||
```
|
||||
|
||||
Prisma types are generated automatically, however, sometimes you might need to:
|
||||
Prisma types are generated automatically post-install, which is quite convenient. However, sometimes you might need to:
|
||||
|
||||
```bash
|
||||
# Generate Prisma client types
|
||||
|
|
@ -23,12 +25,6 @@ $ pnpm prisma migrate dev
|
|||
$ pnpm prisma generate
|
||||
```
|
||||
|
||||
I recommend opting out of Next.js' telemetry program but it is not a requirement.
|
||||
|
||||
```bash
|
||||
$ pnpm exec next telemetry disable
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`:
|
||||
|
|
@ -57,13 +53,7 @@ services:
|
|||
- 6379:6379
|
||||
```
|
||||
|
||||
After starting the docker applications, apply TomodachiShare's database schema migrations.
|
||||
|
||||
```bash
|
||||
$ pnpm prisma migrate dev
|
||||
```
|
||||
|
||||
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest of the variables.
|
||||
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest.
|
||||
|
||||
For the `AUTH_SECRET`, run the following in the command line:
|
||||
|
||||
|
|
@ -71,10 +61,7 @@ For the `AUTH_SECRET`, run the following in the command line:
|
|||
$ pnpx auth secret
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `.env`
|
||||
|
||||
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
|
||||
Now, let's get the Discord and GitHub authentication set up.
|
||||
|
||||
For Discord, create an application in the developer portal, go to 'OAuth2', copy in the Client ID and Secret into the respective variables and also add this as a redirect URL: `http://localhost:3000/api/auth/callback/discord`.
|
||||
|
||||
|
|
@ -25,9 +25,7 @@
|
|||
- 🌎 Browse and add Miis from other players
|
||||
- 🏝️ Build your perfect island by finding the perfect residents
|
||||
|
||||
### <a href="/DEVELOPMENT.md">Development Instructions</a>
|
||||
|
||||
### <a href="/API.md">API Reference</a>
|
||||
### <a href="/DEVELOPMENT.MD">Development Instructions</a>
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
60
package.json
|
|
@ -2,9 +2,9 @@
|
|||
"name": "tomodachi-share",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
|
@ -12,49 +12,49 @@
|
|||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "^3.2.0",
|
||||
"@auth/prisma-adapter": "2.11.1",
|
||||
"@2toad/profanity": "^3.1.1",
|
||||
"@auth/prisma-adapter": "2.10.0",
|
||||
"@bprogress/next": "^3.2.12",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/client": "^6.19.1",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"bit-buffer": "^0.2.5",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.19",
|
||||
"downshift": "^9.0.13",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"dayjs": "^1.11.18",
|
||||
"downshift": "^9.0.10",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^21.1.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"file-type": "^21.0.0",
|
||||
"ioredis": "^5.7.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"next": "15.5.3",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-webcam": "^7.2.0",
|
||||
"satori": "^0.18.3",
|
||||
"satori": "^0.18.2",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.34.3",
|
||||
"sjcl-with-all": "1.0.8",
|
||||
"swr": "^2.3.7",
|
||||
"zod": "^4.1.13"
|
||||
"swr": "^2.3.6",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@iconify/react": "^6.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"prisma": "^6.19.1",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"schema-dts": "^1.1.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.15"
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2698
pnpm-lock.yaml
|
|
@ -0,0 +1,9 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS',
|
||||
ALTER COLUMN "firstName" DROP NOT NULL,
|
||||
ALTER COLUMN "lastName" DROP NOT NULL,
|
||||
ALTER COLUMN "islandName" DROP NOT NULL,
|
||||
ALTER COLUMN "allowedCopying" DROP NOT NULL;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);
|
||||
|
|
@ -14,7 +14,6 @@ model User {
|
|||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
description String? @db.VarChar(256)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -69,18 +68,20 @@ model Session {
|
|||
}
|
||||
|
||||
model Mii {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String @db.VarChar(64)
|
||||
imageCount Int @default(0)
|
||||
tags String[]
|
||||
description String? @db.VarChar(256)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
|
||||
firstName String
|
||||
lastName String
|
||||
name String @db.VarChar(64)
|
||||
imageCount Int @default(0)
|
||||
tags String[]
|
||||
description String? @db.VarChar(256)
|
||||
platform MiiPlatform @default(THREE_DS)
|
||||
|
||||
firstName String?
|
||||
lastName String?
|
||||
gender MiiGender?
|
||||
islandName String
|
||||
allowedCopying Boolean
|
||||
islandName String?
|
||||
allowedCopying Boolean?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
|
@ -154,6 +155,11 @@ model Punishment {
|
|||
@@map("punishments")
|
||||
}
|
||||
|
||||
enum MiiPlatform {
|
||||
SWITCH
|
||||
THREE_DS // can't start with a number
|
||||
}
|
||||
|
||||
enum MiiGender {
|
||||
MALE
|
||||
FEMALE
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
|
@ -5,7 +5,6 @@ import { auth } from "@/lib/auth";
|
|||
|
||||
import BannerForm from "@/components/admin/banner-form";
|
||||
import ControlCenter from "@/components/admin/control-center";
|
||||
import RegenerateImagesButton from "@/components/admin/regenerate-images";
|
||||
import UserManagement from "@/components/admin/user-management";
|
||||
import Reports from "@/components/admin/reports";
|
||||
|
||||
|
|
@ -32,37 +31,36 @@ export default async function AdminPage() {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Banners</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<BannerForm />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Control Center</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<ControlCenter />
|
||||
<RegenerateImagesButton />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>User Management</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<UserManagement />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Reports</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<Reports />
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
|
||||
export async function PATCH() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
// Start processing in background
|
||||
regenerateImages().catch(console.error);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
async function regenerateImages() {
|
||||
// Get miis in batches to reduce memory usage
|
||||
const BATCH_SIZE = 10;
|
||||
const totalMiis = await prisma.mii.count();
|
||||
let processed = 0;
|
||||
|
||||
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
|
||||
const miis = await prisma.mii.findMany({
|
||||
skip,
|
||||
take: BATCH_SIZE,
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
|
||||
// Process each batch sequentially to avoid overwhelming the server
|
||||
for (const mii of miis) {
|
||||
try {
|
||||
await generateMetadataImage(mii, mii.user.name);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate image for mii ${mii.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { profanity } from "@2toad/profanity";
|
||||
import z from "zod";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const { description } = await request.json();
|
||||
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
|
||||
|
||||
const validation = z.string().trim().max(256).safeParse(description);
|
||||
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: Number(session.user.id) },
|
||||
data: { description: profanity.censor(description) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update description:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
|
|
@ -44,6 +44,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
|
|
@ -95,18 +102,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
if (images.length > 0) updateData.imageCount = images.length;
|
||||
|
||||
if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
|
||||
const updatedMii = await prisma.mii.update({
|
||||
await prisma.mii.update({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
data: updateData,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only touch files if new images were uploaded
|
||||
|
|
@ -136,7 +136,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
}
|
||||
} else if (description === undefined) {
|
||||
// If images or description were not changed, regenerate the metadata image
|
||||
await generateMetadataImage(updatedMii, updatedMii.user.name!);
|
||||
try {
|
||||
await generateMetadataImage(mii, mii.user.username!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma, ReportReason, ReportType } from "@prisma/client";
|
||||
import { ReportReason, ReportType } from "@prisma/client";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { MiiWithUsername } from "@/types";
|
||||
|
||||
const reportSchema = z.object({
|
||||
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
|
||||
|
|
@ -29,15 +30,7 @@ export async function POST(request: NextRequest) {
|
|||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const { id, type, reason, notes } = parsed.data;
|
||||
|
||||
let mii: Prisma.MiiGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}> | null = null;
|
||||
let mii: MiiWithUsername | null = null;
|
||||
|
||||
// Check if the Mii or User exists
|
||||
if (type === "mii") {
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import seedrandom from "seedrandom";
|
||||
|
||||
import { searchSchema } from "@/lib/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const rateLimit = new RateLimit(request, 24, "/api/search");
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
|
||||
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
|
||||
|
||||
const where: Prisma.MiiWhereInput = {
|
||||
// Searching
|
||||
...(query && {
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
if (sort === "random") {
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (matchingIds.length === 0) return rateLimit.sendResponse({ error: "No Miis found!" }, 404);
|
||||
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
}
|
||||
|
||||
// Convert to number[] array and return paginated results
|
||||
return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id));
|
||||
} else {
|
||||
// Sorting by likes, newest, or oldest
|
||||
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||
|
||||
if (sort === "likes") {
|
||||
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||
} else if (sort === "oldest") {
|
||||
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||
} else {
|
||||
// default to newest
|
||||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
}
|
||||
|
||||
const list = await prisma.mii.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select: { id: true },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return rateLimit.sendResponse(list.map((mii) => mii.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import sharp from "sharp";
|
|||
|
||||
import qrcode from "qrcode-generator";
|
||||
import { profanity } from "@2toad/profanity";
|
||||
import { MiiGender } from "@prisma/client";
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
|
@ -21,29 +21,52 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
|
|||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||
|
||||
const submitSchema = z.object({
|
||||
name: nameSchema,
|
||||
tags: tagsSchema,
|
||||
description: z.string().trim().max(256).optional(),
|
||||
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }),
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image3: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
});
|
||||
const submitSchema = z
|
||||
.object({
|
||||
platform: z.enum(MiiPlatform).default("THREE_DS"),
|
||||
name: nameSchema,
|
||||
tags: tagsSchema,
|
||||
description: z.string().trim().max(256).optional(),
|
||||
|
||||
// Switch
|
||||
gender: z.enum(MiiGender).default("MALE"),
|
||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
|
||||
// QR code
|
||||
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }),
|
||||
|
||||
// Custom images
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image3: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If platform is Switch, gender and miiPortraitImage must be present
|
||||
if (data.platform === "SWITCH") {
|
||||
return data.gender !== undefined && data.miiPortraitImage !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Gender and Mii portrait image are required for Switch platform",
|
||||
path: ["gender", "miiPortraitImage"],
|
||||
}
|
||||
);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 2);
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||
const { value } = await response.json();
|
||||
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
|
||||
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
|
||||
|
||||
// Parse data
|
||||
// Parse tags and QR code as JSON
|
||||
const formData = await request.formData();
|
||||
|
||||
let rawTags: string[];
|
||||
|
|
@ -52,64 +75,85 @@ export async function POST(request: NextRequest) {
|
|||
rawTags = JSON.parse(formData.get("tags") as string);
|
||||
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
|
||||
} catch {
|
||||
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400);
|
||||
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
|
||||
}
|
||||
|
||||
// Parse and check all submission info
|
||||
const parsed = submitSchema.safeParse({
|
||||
platform: formData.get("platform"),
|
||||
name: formData.get("name"),
|
||||
tags: rawTags,
|
||||
description: formData.get("description"),
|
||||
|
||||
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
|
||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||
|
||||
qrBytesRaw: rawQrBytesRaw,
|
||||
|
||||
image1: formData.get("image1"),
|
||||
image2: formData.get("image2"),
|
||||
image3: formData.get("image3"),
|
||||
});
|
||||
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data;
|
||||
const data = parsed.data;
|
||||
|
||||
// Censor potential inappropriate words
|
||||
const name = profanity.censor(uncensoredName);
|
||||
const tags = uncensoredTags.map((t) => profanity.censor(t));
|
||||
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
|
||||
const name = profanity.censor(data.name);
|
||||
const tags = data.tags.map((t) => profanity.censor(t));
|
||||
const description = data.description && profanity.censor(data.description);
|
||||
|
||||
// Validate image files
|
||||
const images: File[] = [];
|
||||
const customImages: File[] = [];
|
||||
|
||||
for (const img of [image1, image2, image3]) {
|
||||
for (const img of [data.image1, data.image2, data.image3]) {
|
||||
if (!img) continue;
|
||||
|
||||
const imageValidation = await validateImage(img);
|
||||
if (imageValidation.valid) {
|
||||
images.push(img);
|
||||
customImages.push(img);
|
||||
} else {
|
||||
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
|
||||
}
|
||||
}
|
||||
|
||||
const qrBytes = new Uint8Array(qrBytesRaw);
|
||||
// Check Mii portrait image as well (Switch)
|
||||
if (data.platform === "SWITCH") {
|
||||
const imageValidation = await validateImage(data.miiPortraitImage);
|
||||
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
|
||||
}
|
||||
|
||||
// Convert QR code to JS
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
return rateLimit.sendResponse({ error }, 400);
|
||||
const qrBytes = new Uint8Array(data.qrBytesRaw);
|
||||
|
||||
// Convert QR code to JS (3DS)
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
|
||||
if (data.platform === "THREE_DS") {
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
return rateLimit.sendResponse({ error }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Mii in database
|
||||
const miiRecord = await prisma.mii.create({
|
||||
data: {
|
||||
userId: Number(session.user.id),
|
||||
platform: data.platform,
|
||||
name,
|
||||
tags,
|
||||
description,
|
||||
gender: data.gender ?? "MALE",
|
||||
|
||||
firstName: conversion.tomodachiLifeMii.firstName,
|
||||
lastName: conversion.tomodachiLifeMii.lastName,
|
||||
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
|
||||
islandName: conversion.tomodachiLifeMii.islandName,
|
||||
allowedCopying: conversion.mii.allowCopying,
|
||||
// Automatically detect certain information if on 3DS
|
||||
...(data.platform === "THREE_DS" &&
|
||||
conversion && {
|
||||
firstName: conversion.tomodachiLifeMii.firstName,
|
||||
lastName: conversion.tomodachiLifeMii.lastName,
|
||||
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
|
||||
islandName: conversion.tomodachiLifeMii.islandName,
|
||||
allowedCopying: conversion.mii.allowCopying,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -117,33 +161,37 @@ export async function POST(request: NextRequest) {
|
|||
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
|
||||
await fs.mkdir(miiUploadsDirectory, { recursive: true });
|
||||
|
||||
// Download the image of the Mii
|
||||
let studioBuffer: Buffer;
|
||||
try {
|
||||
const studioUrl = conversion.mii.studioUrl({ width: 512 });
|
||||
const studioResponse = await fetch(studioUrl);
|
||||
let portraitBuffer: Buffer | undefined;
|
||||
|
||||
if (!studioResponse.ok) {
|
||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||
// Download the image of the Mii (3DS)
|
||||
if (data.platform === "THREE_DS") {
|
||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||
const studioResponse = await fetch(studioUrl!);
|
||||
|
||||
if (!studioResponse.ok) {
|
||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||
}
|
||||
|
||||
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
|
||||
} else if (data.platform === "SWITCH") {
|
||||
portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer());
|
||||
}
|
||||
|
||||
const studioArrayBuffer = await studioResponse.arrayBuffer();
|
||||
studioBuffer = Buffer.from(studioArrayBuffer);
|
||||
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
|
||||
const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, "mii.webp");
|
||||
|
||||
await fs.writeFile(fileLocation, webpBuffer);
|
||||
} catch (error) {
|
||||
// Clean up if something went wrong
|
||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||
|
||||
console.error("Failed to download Mii image:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
|
||||
console.error("Failed to download/store Mii portrait:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
// Compress and store
|
||||
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
|
||||
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
|
||||
|
||||
await fs.writeFile(studioFileLocation, studioWebpBuffer);
|
||||
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
const generatedCode = qrcode(0, "L");
|
||||
|
|
@ -160,19 +208,25 @@ export async function POST(request: NextRequest) {
|
|||
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
|
||||
|
||||
await fs.writeFile(codeFileLocation, codeWebpBuffer);
|
||||
await generateMetadataImage(miiRecord, session.user.name!);
|
||||
} catch (error) {
|
||||
// Clean up if something went wrong
|
||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||
|
||||
console.error("Error processing Mii files:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
||||
console.error("Error generating QR code:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await generateMetadataImage(miiRecord, session.user.username!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
|
||||
}
|
||||
|
||||
// Compress and store user images
|
||||
try {
|
||||
await Promise.all(
|
||||
images.map(async (image, index) => {
|
||||
customImages.map(async (image, index) => {
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
||||
|
|
@ -187,7 +241,7 @@ export async function POST(request: NextRequest) {
|
|||
id: miiRecord.id,
|
||||
},
|
||||
data: {
|
||||
imageCount: images.length,
|
||||
imageCount: customImages.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold mb-4">Welcome to the island!</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Please create a username</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<UsernameForm />
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ body {
|
|||
}
|
||||
|
||||
.pill {
|
||||
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
|
||||
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
|
@ -64,6 +64,7 @@ body {
|
|||
@apply block;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
[data-tooltip] {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
|
@ -81,7 +82,24 @@ body {
|
|||
@apply opacity-100 scale-100;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
/* Fallback Tooltips */
|
||||
[data-tooltip-span] {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
[data-tooltip-span] > .tooltip {
|
||||
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-[999999];
|
||||
}
|
||||
|
||||
[data-tooltip-span] > .tooltip::before {
|
||||
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
|
||||
}
|
||||
|
||||
[data-tooltip-span]:hover > .tooltip {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-color: #ff8903 transparent;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function RootLayout({
|
|||
<Providers>
|
||||
<Header />
|
||||
<AdminBanner />
|
||||
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
|
||||
<main className="px-4 py-8 max-w-7xl w-full flex-grow flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ export default async function LoginPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Choose your login method</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<LoginButtons />
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const rateLimit = new RateLimit(request, 200, "/mii/data");
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const { id: slugId } = await params;
|
||||
const parsed = idSchema.safeParse(slugId);
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const miiId = parsed.data;
|
||||
|
||||
const data = await prisma.mii.findUnique({
|
||||
where: { id: miiId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
likedBy: true,
|
||||
},
|
||||
},
|
||||
imageCount: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
gender: true,
|
||||
islandName: true,
|
||||
allowedCopying: true,
|
||||
createdAt: true,
|
||||
user: { select: { id: true, username: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
}
|
||||
|
||||
const { _count, ...rest } = data;
|
||||
return rateLimit.sendResponse({
|
||||
...rest,
|
||||
likes: _count.likedBy,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
|
@ -9,6 +8,7 @@ import { idSchema } from "@/lib/schemas";
|
|||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MiiWithUsername } from "@/types";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
type: z
|
||||
|
|
@ -37,15 +37,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
|
||||
let buffer: Buffer | undefined;
|
||||
// Only find Mii if image type is 'metadata'
|
||||
let mii: Prisma.MiiGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}> | null = null;
|
||||
let mii: MiiWithUsername | null = null;
|
||||
|
||||
if (imageType === "metadata") {
|
||||
mii = await prisma.mii.findUnique({
|
||||
|
|
@ -55,7 +47,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -74,13 +66,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
if (imageType === "metadata" && mii) {
|
||||
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
|
||||
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
|
||||
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.name!);
|
||||
|
||||
if (error) {
|
||||
return rateLimit.sendResponse({ error }, status);
|
||||
try {
|
||||
buffer = await generateMetadataImage(mii, mii.user.username!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
|
||||
}
|
||||
|
||||
buffer = metadataBuffer;
|
||||
} else {
|
||||
return rateLimit.sendResponse({ error: "Image not found" }, 404);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import LikeButton from "@/components/like-button";
|
|||
import ImageViewer from "@/components/image-viewer";
|
||||
import DeleteMiiButton from "@/components/delete-mii";
|
||||
import ShareMiiButton from "@/components/share-mii-button";
|
||||
import ScanTutorialButton from "@/components/tutorial/scan";
|
||||
import ProfilePicture from "@/components/profile-picture";
|
||||
import Description from "@/components/description";
|
||||
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
|
||||
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
|
|
@ -49,22 +48,22 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
return {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
|
||||
creator: username,
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||
images: [metadataImageUrl],
|
||||
publishedTime: mii.createdAt.toISOString(),
|
||||
authors: username,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
images: [{ url: metadataImageUrl, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character` }],
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||
images: [metadataImageUrl],
|
||||
creator: username,
|
||||
},
|
||||
alternates: {
|
||||
|
|
@ -84,7 +83,6 @@ export default async function MiiPage({ params }: Props) {
|
|||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -112,13 +110,13 @@ export default async function MiiPage({ params }: Props) {
|
|||
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
|
||||
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
|
||||
{/* Mii Image */}
|
||||
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
|
||||
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50">
|
||||
<ImageViewer
|
||||
src={`/mii/${mii.id}/image?type=mii`}
|
||||
alt="mii headshot"
|
||||
width={200}
|
||||
height={200}
|
||||
className="drop-shadow-lg hover:scale-105 transition-transform"
|
||||
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
|
||||
/>
|
||||
</div>
|
||||
{/* QR Code */}
|
||||
|
|
@ -134,23 +132,76 @@ export default async function MiiPage({ params }: Props) {
|
|||
<hr className="w-full border-t-2 border-t-amber-400" />
|
||||
|
||||
{/* Mii Info */}
|
||||
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
|
||||
<li>
|
||||
Name:{" "}
|
||||
<span className="text-right font-medium">
|
||||
{mii.firstName} {mii.lastName}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
From: <span className="text-right font-medium">{mii.islandName} Island</span>
|
||||
</li>
|
||||
<li>
|
||||
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying} disabled className="checkbox cursor-auto!" />
|
||||
</li>
|
||||
</ul>
|
||||
{mii.platform === "THREE_DS" && (
|
||||
<ul className="text-sm w-full p-2 *:flex *:justify-between *:items-center *:my-1">
|
||||
<li>
|
||||
Name:{" "}
|
||||
<span className="text-right font-medium">
|
||||
{mii.firstName} {mii.lastName}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
From: <span className="text-right font-medium">{mii.islandName} Island</span>
|
||||
</li>
|
||||
<li>
|
||||
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" />
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Mii Platform */}
|
||||
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
|
||||
<div
|
||||
className={`tooltip !mt-1 ${
|
||||
mii.platform === "THREE_DS"
|
||||
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
|
||||
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
|
||||
}`}
|
||||
>
|
||||
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
|
||||
mii.platform === "THREE_DS" ? "bg-sky-100 border-sky-400" : "bg-white border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-sky-500" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-16 text-4xl border-2 shadow-sm ${
|
||||
mii.platform === "SWITCH" ? "bg-red-100 border-red-400" : "bg-white border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mii Gender */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
className={`tooltip !mt-1 ${
|
||||
mii.gender === "MALE"
|
||||
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
|
||||
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
|
||||
}`}
|
||||
>
|
||||
{mii.gender === "MALE" ? "Male" : "Female"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-16 text-5xl border-2 shadow-sm ${
|
||||
mii.gender === "MALE" ? "bg-blue-100 border-blue-400" : "bg-white border-gray-300"
|
||||
|
|
@ -174,7 +225,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-start">
|
||||
{/* Submission name */}
|
||||
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
||||
<h1 className="text-4xl font-extrabold break-words text-amber-700">{mii.name}</h1>
|
||||
{/* Like button */}
|
||||
<LikeButton
|
||||
likes={mii._count.likedBy ?? 0}
|
||||
|
|
@ -196,7 +247,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
{/* Author and Created date */}
|
||||
<div className="mt-2">
|
||||
<Link href={`/profile/${mii.userId}`} className="text-lg">
|
||||
By <span className="font-bold">{mii.user.name}</span>
|
||||
By: <span className="font-bold">@{mii.user.username}</span>
|
||||
</Link>
|
||||
<h4 className="text-sm">
|
||||
Created:{" "}
|
||||
|
|
@ -214,7 +265,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{mii.description && <Description text={mii.description} className="ml-2" />}
|
||||
{mii.description && <p className="text-sm mt-2 ml-2 bg-white/50 p-3 rounded-lg border border-orange-200">{mii.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
|
|
@ -234,7 +285,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
<Icon icon="material-symbols:flag-rounded" />
|
||||
<span>Report</span>
|
||||
</Link>
|
||||
<ScanTutorialButton />
|
||||
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchScanTutorialButton />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -251,7 +302,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
{images.map((src, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
|
||||
className="relative aspect-[3/2] rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
|
|
@ -266,7 +317,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
alt="mii screenshot"
|
||||
width={256}
|
||||
height={170}
|
||||
className="aspect-3/2 w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
|
||||
className="aspect-[3/2] w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
|
||||
images={images}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const metadata: Metadata = {
|
|||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
|
||||
<h2 className="text-7xl font-black">404</h2>
|
||||
<p>Page not found - you swam off the island!</p>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export default async function ExiledPage() {
|
|||
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
|
||||
<h2 className="text-4xl font-black mb-2">
|
||||
{activePunishment.type === "PERM_EXILE"
|
||||
|
|
@ -78,9 +78,9 @@ export default async function ExiledPage() {
|
|||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Violating Items</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
|
|
@ -95,9 +95,7 @@ export default async function ExiledPage() {
|
|||
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
|
||||
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
|
||||
<div className="p-4">
|
||||
<p className="text-xl font-bold line-clamp-1" title={"hello"}>
|
||||
{mii.mii.name}
|
||||
</p>
|
||||
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold">Reason:</span> {mii.reason}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Metadata } from "next";
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import ProfileSettings from "@/components/profile-settings";
|
||||
import ProfileInformation from "@/components/profile-information";
|
||||
|
|
@ -21,12 +20,10 @@ export default async function ProfileSettingsPage() {
|
|||
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProfileInformation page="settings" />
|
||||
<ProfileSettings currentDescription={user?.description} />
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export default function robots(): MetadataRoute.Robots {
|
|||
"/report/mii/*",
|
||||
"/report/user/*",
|
||||
"/admin",
|
||||
"/_next/image",
|
||||
],
|
||||
},
|
||||
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default async function SubmitPage() {
|
|||
|
||||
if (!value)
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
|
||||
<h2 className="text-5xl font-black">Sorry</h2>
|
||||
<p className="mt-1">Submissions are disabled</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
|
@ -18,7 +18,7 @@ function RedirectBanner() {
|
|||
if (from !== "old-domain") return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
||||
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
||||
<Icon icon="humbleicons:link" className="text-2xl min-w-6" />
|
||||
<span>We have moved URLs, welcome to tomodachishare.com!</span>
|
||||
</div>
|
||||
|
|
@ -27,39 +27,13 @@ function RedirectBanner() {
|
|||
|
||||
export default function AdminBanner() {
|
||||
const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
|
||||
const [shouldShow, setShouldShow] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.message) return;
|
||||
|
||||
// Check if the current banner text was closed by the user
|
||||
const closedBanner = window.localStorage.getItem("closedBanner");
|
||||
setShouldShow(data.message !== closedBanner);
|
||||
}, [data]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!data) return;
|
||||
|
||||
// Close banner and remember it
|
||||
window.localStorage.setItem("closedBanner", data.message);
|
||||
setShouldShow(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && data.message && shouldShow && (
|
||||
<div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
|
||||
<div className="flex gap-2 h-full items-center w-fit">
|
||||
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
||||
<span>{data.message}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="min-sm:absolute right-2 cursor-pointer p-1.5"
|
||||
>
|
||||
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
||||
</button>
|
||||
{data && data.message && (
|
||||
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
||||
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
||||
<span>{data.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<Suspense>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function ControlCenter() {
|
|||
<input
|
||||
name="submit"
|
||||
type="checkbox"
|
||||
className="checkbox size-6!"
|
||||
className="checkbox !size-6"
|
||||
placeholder="Enter banner text"
|
||||
checked={canSubmit}
|
||||
onChange={(e) => setCanSubmit(e.target.checked)}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
|
|||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
export default function RegenerateImagesButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
|
||||
Regenerate all Mii metadata images
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Regenerate Images</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
|
|||
disabled={hasExpired}
|
||||
checked={isChecked}
|
||||
onChange={(e) => setIsChecked(e.target.checked)}
|
||||
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
|
||||
className={`checkbox ${hasExpired && "text-zinc-600 !bg-zinc-100 !border-zinc-300"}`}
|
||||
/>
|
||||
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
|
||||
I Agree
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ export default function Punishments() {
|
|||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type notes here for the punishment..."
|
||||
className="pill input rounded-xl! resize-none"
|
||||
className="pill input !rounded-xl resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
|
@ -249,7 +249,7 @@ export default function Punishments() {
|
|||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type profile-related reasons here for the punishment..."
|
||||
className="pill input rounded-xl! resize-none"
|
||||
className="pill input !rounded-xl resize-none"
|
||||
value={reasons}
|
||||
onChange={(e) => setReasons(e.target.value)}
|
||||
/>
|
||||
|
|
@ -273,7 +273,7 @@ export default function Punishments() {
|
|||
value={newMii.reason}
|
||||
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
|
||||
/>
|
||||
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
|
||||
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square !p-2.5">
|
||||
<Icon icon="ic:baseline-plus" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export default function Carousel({ images, className }: Props) {
|
|||
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-3/2 object-contain" images={images} />
|
||||
<div key={index} className="flex-shrink-0 w-full">
|
||||
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-[3/2] object-contain" images={images} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
|
|||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
@ -107,7 +107,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
|
|||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import ProfilePicture from "./profile-picture";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Description({ text, className }: Props) {
|
||||
return (
|
||||
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
|
||||
{/* Adds fancy formatting when linking to other pages on the site */}
|
||||
{(() => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
|
||||
|
||||
// Match both mii and profile links
|
||||
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map(async (part, index) => {
|
||||
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
|
||||
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
|
||||
|
||||
if (miiMatch) {
|
||||
const id = Number(miiMatch[1]);
|
||||
const linkedMii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!linkedMii) return;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/mii/${id}`}
|
||||
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs"
|
||||
>
|
||||
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" />
|
||||
{linkedMii.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileMatch) {
|
||||
const id = Number(profileMatch[1]);
|
||||
const linkedProfile = await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!linkedProfile) return;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/profile/${id}`}
|
||||
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
|
||||
>
|
||||
<ProfilePicture
|
||||
src={linkedProfile.image || "/guest.webp"}
|
||||
width={24}
|
||||
height={24}
|
||||
className="bg-white rounded-lg border-r border-orange-400"
|
||||
/>
|
||||
{linkedProfile.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular text
|
||||
return <span key={index}>{part}</span>;
|
||||
});
|
||||
})()}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
|
|||
{...getRootProps()}
|
||||
onDragOver={() => setIsDraggingOver(true)}
|
||||
onDragLeave={() => setIsDraggingOver(false)}
|
||||
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
|
||||
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none h-full transition-all duration-200 ${
|
||||
isDraggingOver && "scale-105 brightness-90 shadow-xl"
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -28,23 +28,10 @@ export default function Footer() {
|
|||
•
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/48cXBFKvWQ"
|
||||
target="_blank"
|
||||
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" className="text-lg" />
|
||||
Discord
|
||||
</a>
|
||||
|
||||
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/trafficlunar/tomodachi-share"
|
||||
target="_blank"
|
||||
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-end gap-1"
|
||||
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:github" className="text-lg" />
|
||||
Source Code
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
@ -99,7 +99,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
|
||||
<div className="flex h-full items-center">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<div key={index} className="flex-shrink-0 w-full">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default function LoginButtons() {
|
|||
<button
|
||||
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
|
||||
aria-label="Login with Discord"
|
||||
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
|
||||
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" fontSize={32} />
|
||||
Login with Discord
|
||||
|
|
@ -17,7 +17,7 @@ export default function LoginButtons() {
|
|||
<button
|
||||
onClick={() => signIn("github", { redirectTo: "/create-username" })}
|
||||
aria-label="Login with GitHub"
|
||||
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
|
||||
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"
|
||||
>
|
||||
<Icon icon="mdi:github" fontSize={32} />
|
||||
Login with GitHub
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { signOut } from "next-auth/react";
|
|||
export default function LogoutButton() {
|
||||
return (
|
||||
<li title="Logout">
|
||||
<button onClick={() => signOut()} aria-label="Log Out" className="pill button p-0! aspect-square h-full" data-tooltip="Log Out">
|
||||
<button onClick={() => signOut()} aria-label="Log Out" className="pill button !p-0 aspect-square h-full" data-tooltip="Log Out">
|
||||
<Icon icon="ic:round-logout" fontSize={24} />
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
95
src/components/mii-list/filter-menu.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
|
||||
import TagFilter from "./tag-filter";
|
||||
import PlatformSelect from "./platform-select";
|
||||
import GenderSelect from "./gender-select";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags]
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
||||
// Filter menu button handler
|
||||
const handleClick = () => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length;
|
||||
if (platform) count++;
|
||||
if (gender) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, platform, gender]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||
|
||||
<TagFilter />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,8 +17,6 @@ export default function GenderSelect() {
|
|||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (filter) {
|
||||
params.set("gender", filter);
|
||||
} else {
|
||||
|
|
@ -31,24 +29,28 @@ export default function GenderSelect() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<div className="grid grid-cols-2 gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("MALE")}
|
||||
aria-label="Filter for Male Miis"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip !bg-blue-400 !border-blue-400 before:!border-b-blue-400">Male</div>
|
||||
<Icon icon="foundation:male" className="text-blue-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("FEMALE")}
|
||||
aria-label="Filter for Female Miis"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip !bg-pink-400 !border-pink-400 before:!border-b-pink-400">Female</div>
|
||||
<Icon icon="foundation:female" className="text-pink-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { z } from "zod";
|
||||
|
||||
import crypto from "crypto";
|
||||
import seedrandom from "seedrandom";
|
||||
|
||||
import { searchSchema } from "@/lib/schemas";
|
||||
import { querySchema } from "@/lib/schemas";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import GenderSelect from "./gender-select";
|
||||
import TagFilter from "./tag-filter";
|
||||
import FilterMenu from "./filter-menu";
|
||||
import SortSelect from "./sort-select";
|
||||
import Carousel from "../carousel";
|
||||
import LikeButton from "../like-button";
|
||||
|
|
@ -24,13 +23,44 @@ interface Props {
|
|||
inLikesPage?: boolean; // Self-explanatory
|
||||
}
|
||||
|
||||
const searchSchema = z.object({
|
||||
q: querySchema.optional(),
|
||||
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
|
||||
tags: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
),
|
||||
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
|
||||
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
||||
// todo: incorporate tagsSchema
|
||||
// Pages
|
||||
limit: z.coerce
|
||||
.number({ error: "Limit must be a number" })
|
||||
.int({ error: "Limit must be an integer" })
|
||||
.min(1, { error: "Limit must be at least 1" })
|
||||
.max(100, { error: "Limit cannot be more than 100" })
|
||||
.optional(),
|
||||
page: z.coerce
|
||||
.number({ error: "Page must be a number" })
|
||||
.int({ error: "Page must be an integer" })
|
||||
.min(1, { error: "Page must be at least 1" })
|
||||
.optional(),
|
||||
// Random sort
|
||||
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
||||
});
|
||||
|
||||
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
|
||||
const session = await auth();
|
||||
|
||||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
|
||||
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
|
||||
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
|
||||
|
||||
// My Likes page
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
|
@ -52,6 +82,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Profiles
|
||||
|
|
@ -69,6 +101,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
},
|
||||
},
|
||||
}),
|
||||
platform: true,
|
||||
name: true,
|
||||
imageCount: true,
|
||||
tags: true,
|
||||
|
|
@ -95,7 +128,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
|
||||
if (sort === "random") {
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const randomSeed = seed || Math.floor(Math.random() * 1_000_000_000);
|
||||
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
|
|
@ -154,7 +187,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-[56rem]:flex-col">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{totalCount == filteredCount ? (
|
||||
<>
|
||||
|
|
@ -171,9 +204,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 w-full min-[56rem]:max-w-2/3 max-[56rem]:justify-center max-sm:flex-col">
|
||||
<GenderSelect />
|
||||
<TagFilter />
|
||||
<div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center">
|
||||
<FilterMenu />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export default function Pagination({ lastPage }: Props) {
|
|||
aria-label="Go to First Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
||||
page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
|
|
@ -57,7 +57,7 @@ export default function Pagination({ lastPage }: Props) {
|
|||
aria-label="Go to Previous Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
className={`pill !bg-orange-100 !p-0.5 aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</Link>
|
||||
|
|
@ -70,7 +70,7 @@ export default function Pagination({ lastPage }: Props) {
|
|||
href={createPageUrl(number)}
|
||||
aria-label={`Go to Page ${number}`}
|
||||
aria-current={number === page ? "page" : undefined}
|
||||
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||
className={`pill !p-0 w-8 h-8 text-center !rounded-md ${number == page ? "!bg-orange-400" : "!bg-orange-100 hover:!bg-orange-400"}`}
|
||||
>
|
||||
{number}
|
||||
</Link>
|
||||
|
|
@ -79,12 +79,12 @@ export default function Pagination({ lastPage }: Props) {
|
|||
|
||||
{/* Next page */}
|
||||
<Link
|
||||
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||
href={page === lastPage ? "#" : createPageUrl(page + 1)}
|
||||
aria-label="Go to Next Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
||||
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
||||
aria-disabled={page === lastPage}
|
||||
tabIndex={page === lastPage ? -1 : undefined}
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
|
|
@ -92,12 +92,12 @@ export default function Pagination({ lastPage }: Props) {
|
|||
|
||||
{/* Go to last page */}
|
||||
<Link
|
||||
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||
href={page === lastPage ? "#" : createPageUrl(lastPage)}
|
||||
aria-label="Go to Last Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
||||
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
||||
aria-disabled={page === lastPage}
|
||||
tabIndex={page === lastPage ? -1 : undefined}
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
|
|
|
|||
58
src/components/mii-list/platform-select.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { MiiPlatform } from "@prisma/client";
|
||||
|
||||
export default function PlatformSelect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||
|
||||
const handleClick = (platform: MiiPlatform) => {
|
||||
const filter = selected === platform ? null : platform;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (filter) {
|
||||
params.set("platform", filter);
|
||||
} else {
|
||||
params.delete("platform");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("THREE_DS")}
|
||||
aria-label="Filter for 3DS Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip !bg-sky-400 !border-sky-400 before:!border-b-sky-400">3DS</div>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("SWITCH")}
|
||||
aria-label="Filter for Switch Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip !bg-red-400 !border-red-400 before:!border-b-red-400">Switch</div>
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export default function Skeleton() {
|
|||
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-3/2"></div>
|
||||
<div className="aspect-[3/2]"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export default function SortSelect() {
|
|||
if (!selectedItem) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
params.set("sort", selectedItem);
|
||||
|
||||
if (selectedItem == "random") {
|
||||
|
|
@ -39,7 +38,7 @@ export default function SortSelect() {
|
|||
return (
|
||||
<div className="relative w-fit">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
|
||||
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap">
|
||||
<span>Sort by </span>
|
||||
{selectedItem || "Select a way to sort"}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ export default function TagFilter() {
|
|||
if (urlTags === stateTags) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set("tags", stateTags);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { auth } from "@/lib/auth";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import ProfilePicture from "./profile-picture";
|
||||
import Description from "./description";
|
||||
|
||||
interface Props {
|
||||
userId?: number;
|
||||
|
|
@ -35,7 +34,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
|
|||
{/* User information */}
|
||||
<div className="flex flex-col w-full relative py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
|
||||
<h1 className="text-3xl font-extrabold break-words">{user.name}</h1>
|
||||
{isAdmin && (
|
||||
<div data-tooltip="Admin" className="text-orange-400">
|
||||
<Icon icon="mdi:shield-moon" className="text-2xl" />
|
||||
|
|
@ -47,9 +46,9 @@ export default async function ProfileInformation({ userId, page }: Props) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">@{user?.username}</h2>
|
||||
<h2 className="text-black/60 text-sm font-semibold break-words">@{user?.username}</h2>
|
||||
|
||||
<div className="mt-3 text-sm flex gap-8">
|
||||
<div className="mt-auto text-sm flex gap-8">
|
||||
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
|
||||
<span className="font-medium">Created:</span>{" "}
|
||||
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
|
||||
|
|
@ -58,8 +57,6 @@ export default async function ProfileInformation({ userId, page }: Props) {
|
|||
Liked <span className="font-bold">{likedMiis}</span> Miis
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{user.description && <Description text={user.description} className="max-h-32!" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default async function ProfileOverview() {
|
|||
<Link
|
||||
href={`/profile/${session?.user.id}`}
|
||||
aria-label="Go to profile"
|
||||
className="pill button gap-2! p-0! h-full max-w-64"
|
||||
className="pill button !gap-2 !p-0 h-full max-w-64"
|
||||
data-tooltip="Your Profile"
|
||||
>
|
||||
<Image
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ export default function ProfilePicture(props: Partial<ImageProps>) {
|
|||
const { src, ...rest } = props;
|
||||
const [imgSrc, setImgSrc] = useState(src);
|
||||
|
||||
return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} onError={() => setImgSrc("/guest.webp")} />;
|
||||
return <Image {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} width={128} height={128} onError={() => setImgSrc("/guest.webp")} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,14 +42,14 @@ export default function DeleteAccount() {
|
|||
<button
|
||||
name="deletion"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"
|
||||
className="pill button w-fit h-min ml-auto !bg-red-400 !border-red-500 hover:!bg-red-500"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
@ -79,7 +79,7 @@ export default function DeleteAccount() {
|
|||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -9,48 +9,18 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas";
|
|||
import ProfilePictureSettings from "./profile-picture";
|
||||
import SubmitDialogButton from "./submit-dialog-button";
|
||||
import DeleteAccount from "./delete-account";
|
||||
import z from "zod";
|
||||
|
||||
interface Props {
|
||||
currentDescription: string | null | undefined;
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ currentDescription }: Props) {
|
||||
export default function ProfileSettings() {
|
||||
const router = useRouter();
|
||||
|
||||
const [description, setDescription] = useState(currentDescription);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
|
||||
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
|
||||
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
|
||||
|
||||
const usernameDate = dayjs().add(90, "days");
|
||||
|
||||
const handleSubmitDescriptionChange = async (close: () => void) => {
|
||||
const parsed = z.string().trim().max(256).safeParse(description);
|
||||
if (!parsed.success) {
|
||||
setDescriptionChangeError(parsed.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/auth/about-me", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setDescriptionChangeError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSubmitDisplayNameChange = async (close: () => void) => {
|
||||
const parsed = displayNameSchema.safeParse(displayName);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -106,54 +76,25 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Account Info</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{/* Profile Picture */}
|
||||
<ProfilePictureSettings />
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<label className="font-semibold">About Me</label>
|
||||
<p className="text-sm text-zinc-500">Write about yourself on your profile</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1 h-min col-span-2">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
rows={5}
|
||||
maxLength={256}
|
||||
placeholder="(optional) Type about yourself..."
|
||||
className="pill input rounded-xl! resize-none text-sm w-full"
|
||||
value={description || ""}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-zinc-400 mt-1 text-right">{(description || "").length}/256</p>
|
||||
</div>
|
||||
|
||||
<SubmitDialogButton
|
||||
title="Confirm About Me Change"
|
||||
description="Are you sure? You can change it again later."
|
||||
error={descriptionChangeError}
|
||||
onSubmit={handleSubmitDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Name */}
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||
<div>
|
||||
<label className="font-semibold">Change Display Name</label>
|
||||
<p className="text-sm text-zinc-500">This is a display name shown on your profile — feel free to change it anytime</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1 h-min col-span-2">
|
||||
<div className="flex justify-end gap-1 h-min">
|
||||
<input
|
||||
type="text"
|
||||
className="pill input flex-1"
|
||||
className="pill input w-full max-w-64"
|
||||
placeholder="Type here..."
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
|
|
@ -173,14 +114,14 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Change Username */}
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||
<div>
|
||||
<label className="font-semibold">Change Username</label>
|
||||
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1 col-span-2">
|
||||
<div className="relative flex-1">
|
||||
<div className="flex justify-end gap-1">
|
||||
<div className="relative w-full max-w-64">
|
||||
<input
|
||||
type="text"
|
||||
className="pill input w-full indent-4"
|
||||
|
|
@ -211,9 +152,9 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Danger Zone</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{/* Delete Account */}
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@ export default function ProfilePictureSettings() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
|
||||
<div className="col-span-3">
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<label className="font-semibold">Profile Picture</label>
|
||||
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col col-span-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-end">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-xs">
|
||||
|
|
@ -74,7 +74,7 @@ export default function ProfilePictureSettings() {
|
|||
data-tooltip="Delete Picture"
|
||||
aria-label="Delete Picture"
|
||||
onClick={() => setNewPicture(undefined)}
|
||||
className="pill button aspect-square p-1! text-2xl bg-red-400! border-red-500!"
|
||||
className="pill button aspect-square !p-1 text-2xl !bg-red-400 !border-red-500"
|
||||
>
|
||||
<Icon icon="mdi:trash-outline" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
|
|||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 !p-1 text-2xl">
|
||||
<Icon icon="material-symbols:check-rounded" />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Icon } from "@iconify/react";
|
|||
|
||||
export default function RandomLink() {
|
||||
return (
|
||||
<Link href={"/random"} aria-label="Go to Random Link" className="pill button p-0! h-full aspect-square" data-tooltip="Go to a Random Mii">
|
||||
<Link href={"/random"} aria-label="Go to Random Link" className="pill button !p-0 h-full aspect-square" data-tooltip="Go to a Random Mii">
|
||||
<Icon icon="mdi:dice-3" fontSize={28} />
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default function ReportMiiForm({ mii, likes }: Props) {
|
|||
rows={3}
|
||||
maxLength={256}
|
||||
placeholder="Type notes here for the report..."
|
||||
className="pill input rounded-xl! resize-none col-span-2"
|
||||
className="pill input !rounded-xl resize-none col-span-2"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
|
|||
type="button"
|
||||
{...getToggleButtonProps()}
|
||||
aria-label="Report reason dropdown"
|
||||
className="pill input w-full gap-1 justify-between! text-nowrap"
|
||||
className="pill input w-full gap-1 !justify-between text-nowrap"
|
||||
>
|
||||
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default function ReportUserForm({ user }: Props) {
|
|||
rows={3}
|
||||
maxLength={256}
|
||||
placeholder="Type notes here for the report..."
|
||||
className="pill input rounded-xl! resize-none col-span-2"
|
||||
className="pill input !rounded-xl resize-none col-span-2"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
|
|
@ -92,7 +92,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
className="absolute! top-2.5 right-2.5 cursor-pointer"
|
||||
className="!absolute top-2.5 right-2.5 cursor-pointer"
|
||||
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
|
|
@ -118,9 +118,9 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>or</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
|
||||
|
|
@ -139,7 +139,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
{/* Save button */}
|
||||
<a
|
||||
href={`/mii/${miiId}/image?type=metadata`}
|
||||
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
|
||||
className="pill button !p-0 aspect-square cursor-pointer text-xl"
|
||||
aria-label="Save Image"
|
||||
data-tooltip="Save Image"
|
||||
download={"hello.png"}
|
||||
|
|
@ -149,7 +149,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
className="pill button p-0! aspect-square size-11 cursor-pointer"
|
||||
className="pill button !p-0 aspect-square cursor-pointer"
|
||||
aria-label="Copy Image"
|
||||
data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"}
|
||||
onClick={handleCopyImage}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
return (
|
||||
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
<Carousel
|
||||
images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]}
|
||||
/>
|
||||
|
|
@ -139,9 +139,9 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Info</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
|
|
@ -164,7 +164,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
<label htmlFor="tags" className="font-semibold">
|
||||
Tags
|
||||
</label>
|
||||
<TagSelector tags={tags} setTags={setTags} showTagLimit />
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-3 items-start">
|
||||
|
|
@ -172,10 +172,10 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
rows={3}
|
||||
maxLength={256}
|
||||
placeholder="(optional) Type a description..."
|
||||
className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
className="pill input !rounded-xl resize-none col-span-2"
|
||||
value={description ?? ""}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
|
@ -183,9 +183,9 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Custom images</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full self-center">
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@ export default function ImageList({ files, setFiles }: Props) {
|
|||
alt={file.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
|
||||
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
|
||||
/>
|
||||
<div className="flex flex-col justify-center w-full min-w-0">
|
||||
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
|
||||
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { FileWithPath } from "react-dropzone";
|
|||
import { Icon } from "@iconify/react";
|
||||
|
||||
import qrcode from "qrcode-generator";
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
|
||||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||
import { convertQrCode } from "@/lib/qr-codes";
|
||||
|
|
@ -15,15 +16,28 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
|
|||
|
||||
import TagSelector from "../tag-selector";
|
||||
import ImageList from "./image-list";
|
||||
import PortraitUpload from "./portrait-upload";
|
||||
import QrUpload from "./qr-upload";
|
||||
import QrScanner from "./qr-scanner";
|
||||
import SubmitTutorialButton from "../tutorial/submit";
|
||||
import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
|
||||
import ThreeDsSubmitTutorialButton from "../tutorial/3ds-submit";
|
||||
import LikeButton from "../like-button";
|
||||
import Carousel from "../carousel";
|
||||
import SubmitButton from "../submit-button";
|
||||
import Dropzone from "../dropzone";
|
||||
|
||||
export default function SubmitForm() {
|
||||
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
|
||||
const [name, setName] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [gender, setGender] = useState<MiiGender>("MALE");
|
||||
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
|
||||
|
||||
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
|
||||
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
|
@ -34,17 +48,6 @@ export default function SubmitForm() {
|
|||
[files.length]
|
||||
);
|
||||
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
const [studioUrl, setStudioUrl] = useState<string | undefined>();
|
||||
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate before sending request
|
||||
const nameValidation = nameSchema.safeParse(name);
|
||||
|
|
@ -60,6 +63,7 @@ export default function SubmitForm() {
|
|||
|
||||
// Send request to server
|
||||
const formData = new FormData();
|
||||
formData.append("platform", platform);
|
||||
formData.append("name", name);
|
||||
formData.append("tags", JSON.stringify(tags));
|
||||
formData.append("description", description);
|
||||
|
|
@ -69,6 +73,14 @@ export default function SubmitForm() {
|
|||
formData.append(`image${index + 1}`, file);
|
||||
});
|
||||
|
||||
if (platform === "SWITCH") {
|
||||
const response = await fetch(miiPortraitUri!);
|
||||
const blob = await response.blob();
|
||||
|
||||
formData.append("gender", gender);
|
||||
formData.append("miiPortraitImage", blob);
|
||||
}
|
||||
|
||||
const response = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
|
@ -96,38 +108,41 @@ export default function SubmitForm() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Convert QR code to JS
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
// Convert QR code to JS (3DS)
|
||||
if (platform === "THREE_DS") {
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
try {
|
||||
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
|
||||
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
const generatedCode = qrcode(0, "L");
|
||||
generatedCode.addData(byteString, "Byte");
|
||||
generatedCode.make();
|
||||
|
||||
setGeneratedQrCodeUrl(generatedCode.createDataURL());
|
||||
setGeneratedQrCodeUri(generatedCode.createDataURL());
|
||||
} catch {
|
||||
setError("Failed to get and/or generate Mii images");
|
||||
setError("Failed to regenerate QR code");
|
||||
}
|
||||
};
|
||||
|
||||
preview();
|
||||
}, [qrBytesRaw]);
|
||||
}, [qrBytesRaw, platform]);
|
||||
|
||||
return (
|
||||
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
<Carousel images={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
|
||||
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
<Carousel
|
||||
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
||||
|
|
@ -157,11 +172,51 @@ export default function SubmitForm() {
|
|||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Info</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{/* Platform select */}
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
<label htmlFor="name" className="font-semibold">
|
||||
Platform
|
||||
</label>
|
||||
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
|
||||
{/* Animated indicator */}
|
||||
<div
|
||||
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
|
||||
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
{/* Switch button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform("SWITCH")}
|
||||
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
|
||||
platform === "SWITCH" && "!text-black"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="cib:nintendo-switch" className="text-2xl" />
|
||||
Switch
|
||||
</button>
|
||||
|
||||
{/* 3DS button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform("THREE_DS")}
|
||||
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
|
||||
platform === "THREE_DS" && "!text-black"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
|
||||
3DS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
<label htmlFor="name" className="font-semibold">
|
||||
Name
|
||||
|
|
@ -182,53 +237,103 @@ export default function SubmitForm() {
|
|||
<label htmlFor="tags" className="font-semibold">
|
||||
Tags
|
||||
</label>
|
||||
<TagSelector tags={tags} setTags={setTags} showTagLimit />
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="w-full grid grid-cols-3 items-start">
|
||||
<label htmlFor="reason-note" className="font-semibold py-2">
|
||||
<label htmlFor="description" className="font-semibold py-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
name="description"
|
||||
rows={3}
|
||||
maxLength={256}
|
||||
placeholder="(optional) Type a description..."
|
||||
className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
className="pill input !rounded-xl resize-none col-span-2"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
{/* Gender (switch only) */}
|
||||
{platform === "SWITCH" && (
|
||||
<div className="w-full grid grid-cols-3 items-start">
|
||||
<label htmlFor="gender" className="font-semibold py-2">
|
||||
Gender
|
||||
</label>
|
||||
<div className="col-span-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGender("MALE")}
|
||||
aria-label="Filter for Male Miis"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
|
||||
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="foundation:male" className="text-blue-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGender("FEMALE")}
|
||||
aria-label="Filter for Female Miis"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
|
||||
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="foundation:female" className="text-pink-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform === "SWITCH" && (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Mii Portrait</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<PortraitUpload setImage={setMiiPortraitUri} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* QR code selector */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>QR Code</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<QrUpload setQrBytesRaw={setQrBytesRaw} />
|
||||
<span>or</span>
|
||||
<QrScanner setQrBytesRaw={setQrBytesRaw} />
|
||||
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
{platform === "THREE_DS" ? (
|
||||
<>
|
||||
<ThreeDsSubmitTutorialButton />
|
||||
|
||||
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
|
||||
<SubmitTutorialButton />
|
||||
|
||||
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
|
||||
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
|
||||
</>
|
||||
) : (
|
||||
<SwitchSubmitTutorialButton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
{/* Custom images selector */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Custom images</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full self-center flex flex-col items-center">
|
||||
<div className="max-w-md w-full self-center">
|
||||
<Dropzone onDrop={handleDrop}>
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your images here
|
||||
|
|
@ -236,8 +341,6 @@ export default function SubmitForm() {
|
|||
or click to open
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
|
||||
</div>
|
||||
|
||||
<ImageList files={files} setFiles={setFiles} />
|
||||
|
|
|
|||
36
src/components/submit-form/portrait-upload.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { FileWithPath } from "react-dropzone";
|
||||
import Dropzone from "../dropzone";
|
||||
|
||||
interface Props {
|
||||
setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
export default function PortraitUpload({ setImage }: Props) {
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
// Convert to Data URI
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
setImage(event.target!.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setImage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your Mii's portrait here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,12 +9,11 @@ import QrFinder from "./qr-finder";
|
|||
import { useSelect } from "downshift";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||
export default function QrScanner({ setQrBytesRaw }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
|
|
@ -127,105 +126,112 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
};
|
||||
}, [isOpen, permissionGranted, scanQRCode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<>
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{devices.length > 1 && (
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold">Camera:</label>
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select camera dropdown"
|
||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
||||
>
|
||||
{selectedItem?.label || "Select a camera"}
|
||||
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps({}, { suppressRefError: true })}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isDropdownOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isDropdownOpen &&
|
||||
cameraItems.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{devices.length > 1 && (
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold">Camera:</label>
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select camera dropdown"
|
||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
|
||||
>
|
||||
{selectedItem?.label || "Select a camera"}
|
||||
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps({}, { suppressRefError: true })}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isDropdownOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isDropdownOpen &&
|
||||
cameraItems.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-square">
|
||||
{!permissionGranted ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
|
||||
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
|
||||
Request Permission
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Webcam
|
||||
key={selectedDeviceId}
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
videoConstraints={{
|
||||
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
|
||||
}}
|
||||
onUserMedia={async () => {
|
||||
const newDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
}}
|
||||
className="size-full object-cover rounded-2xl border-2 border-amber-500"
|
||||
/>
|
||||
<QrFinder />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-square">
|
||||
{!permissionGranted ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
|
||||
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
|
||||
Request Permission
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Webcam
|
||||
key={selectedDeviceId}
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
videoConstraints={{
|
||||
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
|
||||
}}
|
||||
onUserMedia={async () => {
|
||||
const newDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
}}
|
||||
className="size-full object-cover rounded-2xl border-2 border-amber-500"
|
||||
/>
|
||||
<QrFinder />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,32 +14,32 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
|
|||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
acceptedFiles.forEach((file) => {
|
||||
// Scan QR code
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
// Scan QR code
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const code = jsQR(imageData.data, image.width, image.height);
|
||||
if (!code) return;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
};
|
||||
image.src = event.target!.result as string;
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const code = jsQR(imageData.data, image.width, image.height);
|
||||
if (!code) return;
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
image.src = event.target!.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setQrBytesRaw]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
showTagLimit?: boolean;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||
export default function TagSelector({ tags, setTags }: Props) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
|
@ -25,7 +23,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
const hasSelectedItems = tags.length > 0;
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
|
||||
if (!tags.includes(tag) && tags.length < 8) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
|
@ -34,7 +32,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
|
|
@ -63,82 +61,65 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!isMaxItemsSelected) {
|
||||
inputRef.current?.focus();
|
||||
openMenu();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 relative">
|
||||
<div
|
||||
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
|
||||
tags.length > 0 ? "py-1.5! px-2!" : ""
|
||||
}`}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete Tag"
|
||||
className="text-black cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
||||
maxLength: 20,
|
||||
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{hasSelectedItems && (
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
<div
|
||||
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
|
||||
tags.length > 0 ? "!py-1.5" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete Tag"
|
||||
className="text-black cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle Tag Dropdown"
|
||||
{...getToggleButtonProps()}
|
||||
disabled={isMaxItemsSelected}
|
||||
className="text-black cursor-pointer text-xl disabled:text-black/35"
|
||||
>
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
||||
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasSelectedItems && (
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{filteredItems.map((item, index) => (
|
||||
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
filteredItems.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
|
|
@ -147,30 +128,18 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
{item}
|
||||
</li>
|
||||
))}
|
||||
{inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag limit message */}
|
||||
{showTagLimit && (
|
||||
<div className="mt-1.5 text-xs min-h-4">
|
||||
{isMaxItemsSelected ? (
|
||||
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
|
||||
) : (
|
||||
<span className="text-black/60">{tags.length}/8 tags</span>
|
||||
{isOpen && inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
104
src/components/tutorial/3ds-scan.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import TutorialPage from "./page";
|
||||
|
||||
export default function ThreeDsScanTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/3ds/step1.png" />
|
||||
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/3ds/adding-mii/step2.png" />
|
||||
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/3ds/adding-mii/step3.png" />
|
||||
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/3ds/adding-mii/step4.png" />
|
||||
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/3ds/adding-mii/step5.png" />
|
||||
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2 px-6 pb-6">
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
aria-label="Scroll Carousel Left"
|
||||
className="pill button !p-1 aspect-square text-2xl"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm">Adding Mii to Island</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
aria-label="Scroll Carousel Right"
|
||||
className="pill button !p-1 aspect-square text-2xl"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
src/components/tutorial/3ds-submit.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import TutorialPage from "./page";
|
||||
import StartingPage from "./starting-page";
|
||||
|
||||
export default function ThreeDsSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
|
||||
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
<StartingPage 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import confetti from "canvas-confetti";
|
||||
import ReturnToIsland from "../admin/return-to-island";
|
||||
|
||||
interface Slide {
|
||||
// step is never used, undefined is assumed as a step
|
||||
type?: "start" | "step" | "finish";
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
interface Tutorial {
|
||||
title: string;
|
||||
thumbnail?: string;
|
||||
hint?: string;
|
||||
steps: Slide[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tutorials: Tutorial[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Build index map
|
||||
const slides: Array<Slide & { tutorialTitle: string }> = [];
|
||||
const startSlides: Record<string, number> = {};
|
||||
|
||||
tutorials.forEach((tutorial) => {
|
||||
tutorial.steps.forEach((slide) => {
|
||||
if (slide.type === "start") {
|
||||
startSlides[tutorial.title] = slides.length;
|
||||
}
|
||||
slides.push({ ...slide, tutorialTitle: tutorial.title });
|
||||
});
|
||||
});
|
||||
|
||||
const currentSlide = slides[selectedIndex];
|
||||
const isStartingPage = currentSlide?.type === "start";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSlide.type !== "finish") return;
|
||||
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
});
|
||||
}, 300);
|
||||
}, [currentSlide]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goToTutorial = (tutorialTitle: string) => {
|
||||
if (!emblaApi) return;
|
||||
const index = startSlides[tutorialTitle];
|
||||
|
||||
// Jump to next starting slide then transition to actual tutorial
|
||||
emblaApi.scrollTo(index, true);
|
||||
emblaApi.scrollTo(index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-120 transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
{slides.map((slide, index) => (
|
||||
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
|
||||
{slide.type === "start" ? (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Pick a tutorial</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
{tutorials.map((tutorial, tutorialIndex) => (
|
||||
<button
|
||||
key={tutorialIndex}
|
||||
onClick={() => goToTutorial(tutorial.title)}
|
||||
aria-label={tutorial.title + " tutorial"}
|
||||
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<Image
|
||||
src={tutorial.thumbnail!}
|
||||
alt="tutorial thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300"
|
||||
/>
|
||||
<p className="mt-2">{tutorial.title}</p>
|
||||
{/* Set opacity to 0 to keep height the same with other tutorials */}
|
||||
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : slide.type === "finish" ? (
|
||||
<div className="h-full flex flex-col justify-center items-center">
|
||||
<Icon icon="fxemoji:partypopper" className="text-9xl" />
|
||||
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||
|
||||
<Image
|
||||
src={slide.imageSrc ?? "/missing.svg"}
|
||||
alt="step image"
|
||||
width={396}
|
||||
height={320}
|
||||
loading="eager"
|
||||
className="rounded-lg w-full h-full object-contain bg-black flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrows */}
|
||||
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Left"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
{/* Only show tutorial name on step slides */}
|
||||
<span
|
||||
className={`text-sm transition-opacity duration-300 ${
|
||||
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{currentSlide?.tutorialTitle}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Right"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/tutorial/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
interface Props {
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
carouselIndex?: number;
|
||||
finishIndex?: number;
|
||||
}
|
||||
|
||||
export default function TutorialPage({ text, imageSrc, carouselIndex, finishIndex }: Props) {
|
||||
useEffect(() => {
|
||||
if (carouselIndex !== finishIndex || !carouselIndex || !finishIndex) return;
|
||||
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
});
|
||||
}, 300);
|
||||
}, [carouselIndex, finishIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 flex flex-col w-full px-6">
|
||||
{!finishIndex ? (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 mb-2 text-center">{text}</p>
|
||||
|
||||
<Image
|
||||
src={imageSrc ?? "/missing.svg"}
|
||||
alt="step image"
|
||||
width={396}
|
||||
height={320}
|
||||
className="rounded-lg w-full h-full object-contain bg-black flex-1"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col justify-center items-center">
|
||||
<Icon icon="fxemoji:partypopper" className="text-9xl" />
|
||||
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function ScanTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
|
||||
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/adding-mii/step2.png" },
|
||||
{ text: "3. Press 'Scan QR Code'", imageSrc: "/tutorial/adding-mii/step3.png" },
|
||||
{ text: "4. Click on the QR code below the Mii's image", imageSrc: "/tutorial/adding-mii/step4.png" },
|
||||
{ text: "5. Scan with your 3DS", imageSrc: "/tutorial/adding-mii/step5.png" },
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
src/components/tutorial/starting-page.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Image from "next/image";
|
||||
import { UseEmblaCarouselType } from "embla-carousel-react";
|
||||
|
||||
interface Props {
|
||||
emblaApi: UseEmblaCarouselType[1] | undefined;
|
||||
isSwitch?: boolean;
|
||||
}
|
||||
|
||||
export default function StartingPage({ emblaApi, isSwitch }: Props) {
|
||||
const goToTutorial = (index: number) => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
emblaApi.scrollTo(index - 1, true);
|
||||
emblaApi.scrollTo(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 flex flex-col w-full px-6 py-6">
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Pick a tutorial</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
<button
|
||||
onClick={() => goToTutorial(1)}
|
||||
aria-label="Allow Copying Tutorial"
|
||||
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<Image
|
||||
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
|
||||
alt="Allow Copying thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300"
|
||||
/>
|
||||
<p className="mt-2">Allow Copying</p>
|
||||
<p className="text-[0.65rem] text-zinc-400">Suggested!</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => goToTutorial(10)}
|
||||
aria-label="Create QR Code Tutorial"
|
||||
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<Image
|
||||
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
|
||||
alt="Creating QR code thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300"
|
||||
/>
|
||||
<p className="mt-2">Create QR Code</p>
|
||||
{/* Add placeholder to keep height the same */}
|
||||
<p className="text-[0.65rem] opacity-0">placeholder</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Allow Copying",
|
||||
thumbnail: "/tutorial/allow-copying/thumbnail.png",
|
||||
hint: "Suggested!",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
|
||||
{ text: "2. Go into 'Mii List'", imageSrc: "/tutorial/allow-copying/step2.png" },
|
||||
{ text: "3. Select and edit the Mii you wish to submit", imageSrc: "/tutorial/allow-copying/step3.png" },
|
||||
{ text: "4. Click 'Other Settings' in the information screen", imageSrc: "/tutorial/allow-copying/step4.png" },
|
||||
{ text: "5. Click on 'Don't Allow' under the 'Copying' text", imageSrc: "/tutorial/allow-copying/step5.png" },
|
||||
{ text: "6. Press 'Allow'", imageSrc: "/tutorial/allow-copying/step6.png" },
|
||||
{ text: "7. Confirm the edits to the Mii", imageSrc: "/tutorial/allow-copying/step7.png" },
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create QR Code",
|
||||
thumbnail: "/tutorial/create-qr-code/thumbnail.png",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
|
||||
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/create-qr-code/step2.png" },
|
||||
{ text: "3. Press 'Create QR Code'", imageSrc: "/tutorial/create-qr-code/step3.png" },
|
||||
{ text: "4. Select and press 'OK' on the Mii you wish to submit", imageSrc: "/tutorial/create-qr-code/step4.png" },
|
||||
{
|
||||
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
|
||||
imageSrc: "/tutorial/create-qr-code/step5.png",
|
||||
},
|
||||
{
|
||||
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
|
||||
imageSrc: "/tutorial/create-qr-code/step6.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/tutorial/switch-scan.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import TutorialPage from "./page";
|
||||
|
||||
export default function SwitchScanTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
|
||||
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/adding-mii/step2.png" />
|
||||
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/switch/adding-mii/step3.png" />
|
||||
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/switch/adding-mii/step4.png" />
|
||||
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/switch/adding-mii/step5.png" />
|
||||
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2 px-6 pb-6">
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
aria-label="Scroll Carousel Left"
|
||||
className="pill button !p-1 aspect-square text-2xl"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm">Adding Mii to Island</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
aria-label="Scroll Carousel Right"
|
||||
className="pill button !p-1 aspect-square text-2xl"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/components/tutorial/switch-submit.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import TutorialPage from "./page";
|
||||
import StartingPage from "./starting-page";
|
||||
|
||||
export default function SwitchSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
|
||||
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
<StartingPage isSwitch emblaApi={emblaApi} />
|
||||
|
||||
{/* Allow Copying */}
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
|
||||
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/switch/allow-copying/step2.png" />
|
||||
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/switch/allow-copying/step3.png" />
|
||||
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/switch/allow-copying/step4.png" />
|
||||
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/switch/allow-copying/step5.png" />
|
||||
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/switch/allow-copying/step6.png" />
|
||||
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/switch/allow-copying/step7.png" />
|
||||
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
|
||||
|
||||
<StartingPage emblaApi={emblaApi} />
|
||||
|
||||
{/* Create QR Code */}
|
||||
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
|
||||
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/create-qr-code/step2.png" />
|
||||
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/switch/create-qr-code/step3.png" />
|
||||
<TutorialPage
|
||||
text="4. Select and press 'OK' on the Mii you wish to submit"
|
||||
imageSrc="/tutorial/switch/create-qr-code/step4.png"
|
||||
/>
|
||||
<TutorialPage
|
||||
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
|
||||
imageSrc="/tutorial/switch/create-qr-code/step5.png"
|
||||
/>
|
||||
<TutorialPage
|
||||
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)."
|
||||
imageSrc="/tutorial/switch/create-qr-code/step6.png"
|
||||
/>
|
||||
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
|
||||
aria-label="Scroll Carousel Left"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
|
||||
aria-label="Scroll Carousel Right"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import satori, { Font } from "satori";
|
|||
|
||||
import { Mii } from "@prisma/client";
|
||||
|
||||
const MIN_IMAGE_DIMENSIONS = [128, 128];
|
||||
const MIN_IMAGE_DIMENSIONS = [320, 240];
|
||||
const MAX_IMAGE_DIMENSIONS = [1920, 1080];
|
||||
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
|
@ -48,7 +48,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
|
|||
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
|
||||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
|
||||
) {
|
||||
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080" };
|
||||
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 320x240 and 1920x1080" };
|
||||
}
|
||||
|
||||
// Check for inappropriate content
|
||||
|
|
@ -121,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
|
|||
);
|
||||
};
|
||||
|
||||
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> {
|
||||
export async function generateMetadataImage(mii: Mii, author: string): Promise<Buffer> {
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
|
||||
|
||||
// Load assets concurrently
|
||||
|
|
@ -146,8 +146,14 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
|||
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
|
||||
<div tw="flex w-full">
|
||||
{/* Mii image */}
|
||||
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
|
||||
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} />
|
||||
<div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
|
||||
<img
|
||||
src={miiImage}
|
||||
width={248}
|
||||
height={248}
|
||||
tw="w-full h-full"
|
||||
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
|
|
@ -162,29 +168,22 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
|||
{mii.name}
|
||||
</span>
|
||||
{/* Tags */}
|
||||
<div id="tags" tw="relative flex mt-1 w-full overflow-hidden">
|
||||
<div tw="flex">
|
||||
{mii.tags.map((tag) => (
|
||||
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm shrink-0">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div tw="absolute inset-0" style={{ position: "absolute", backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);" }}></div>
|
||||
<div id="tags" tw="flex flex-wrap mt-1 w-full">
|
||||
{mii.tags.map((tag) => (
|
||||
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<div tw="flex mt-2 text-sm w-1/2">
|
||||
By{" "}
|
||||
<span tw="ml-1.5 font-semibold overflow-hidden" style={{ textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{author}
|
||||
</span>
|
||||
<div tw="flex text-sm mt-2">
|
||||
By: <span tw="ml-1.5 font-semibold">@{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Watermark */}
|
||||
<div tw="absolute bottom-0 right-0 flex items-center">
|
||||
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
|
||||
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={34} />
|
||||
{/* I tried using text-orange-400 but it wasn't correct..? */}
|
||||
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
|
||||
TomodachiShare
|
||||
|
|
@ -204,16 +203,11 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
|||
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
// Store the file
|
||||
try {
|
||||
// I tried using .webp here but the quality looked awful
|
||||
// but it actually might be well-liked due to the hatred of .webp
|
||||
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
|
||||
await fs.writeFile(fileLocation, buffer);
|
||||
} catch (error) {
|
||||
console.error("Error storing 'metadata' image type", error);
|
||||
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
|
||||
}
|
||||
// I tried using .webp here but the quality looked awful
|
||||
// but it actually might be well-liked due to the hatred of .webp
|
||||
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
|
||||
await fs.writeFile(fileLocation, buffer);
|
||||
|
||||
return { buffer };
|
||||
return buffer;
|
||||
}
|
||||
//#endregion
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { MiiGender } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
// profanity censoring bypasses the regex in some of these but I think it's funny
|
||||
|
|
@ -27,7 +26,7 @@ export const tagsSchema = z
|
|||
z
|
||||
.string()
|
||||
.min(2, { error: "Tags must be at least 2 characters long" })
|
||||
.max(20, { error: "Tags cannot be more than 20 characters long" })
|
||||
.max(64, { error: "Tags cannot be more than 20 characters long" })
|
||||
.regex(/^[a-z0-9-_]+$/, {
|
||||
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
||||
})
|
||||
|
|
@ -40,36 +39,6 @@ export const idSchema = z.coerce
|
|||
.int({ error: "ID must be an integer" })
|
||||
.positive({ error: "ID must be valid" });
|
||||
|
||||
export const searchSchema = z.object({
|
||||
q: querySchema.optional(),
|
||||
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
|
||||
tags: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
),
|
||||
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
||||
// todo: incorporate tagsSchema
|
||||
// Pages
|
||||
limit: z.coerce
|
||||
.number({ error: "Limit must be a number" })
|
||||
.int({ error: "Limit must be an integer" })
|
||||
.min(1, { error: "Limit must be at least 1" })
|
||||
.max(100, { error: "Limit cannot be more than 100" })
|
||||
.optional(),
|
||||
page: z.coerce
|
||||
.number({ error: "Page must be a number" })
|
||||
.int({ error: "Page must be an integer" })
|
||||
.min(1, { error: "Page must be at least 1" })
|
||||
.optional(),
|
||||
// Random sort
|
||||
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
||||
});
|
||||
|
||||
// Account Info
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
|
|
|
|||
10
src/types.d.ts
vendored
|
|
@ -12,3 +12,13 @@ declare module "next-auth" {
|
|||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
type MiiWithUsername = Prisma.MiiGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"sjcl-with-all": ["./node_modules/@types/sjcl"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"sjcl-with-all": ["./node_modules/@types/sjcl"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||