mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
Merge branch 'feat/living-the-dream-qr-code' into feat/living-the-dream-access-key
This commit is contained in:
commit
7e182725ac
30 changed files with 3177 additions and 3109 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,6 +16,7 @@
|
|||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
certificates/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
|
|
|||
130
API.md
130
API.md
|
|
@ -1,130 +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",
|
||||
"platform": "THREE_DS",
|
||||
"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_
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
## About
|
||||
|
||||
**TomodachiShare** is a fan-made website that lets you easily discover, upload, and share **Mii characters** for the game **Tomodachi Life**.
|
||||
TomodachiShare is a fan-made website that lets you easily discover, upload, and share Mii characters for the game Tomodachi Life.
|
||||
|
||||
- 📷 Upload or scan your Mii QR codes
|
||||
- ✨ Generates Mii renders for previews
|
||||
|
|
@ -27,8 +27,6 @@
|
|||
|
||||
### <a href="/DEVELOPMENT.md">Development Instructions</a>
|
||||
|
||||
### <a href="/API.md">API Reference</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
69
package.json
69
package.json
|
|
@ -2,9 +2,9 @@
|
|||
"name": "tomodachi-share",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
|
@ -12,55 +12,48 @@
|
|||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "^3.1.1",
|
||||
"@auth/prisma-adapter": "2.10.0",
|
||||
"@2toad/profanity": "^3.2.0",
|
||||
"@auth/prisma-adapter": "2.11.1",
|
||||
"@bprogress/next": "^3.2.12",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"bit-buffer": "^0.2.5",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"dayjs": "^1.11.18",
|
||||
"downshift": "^9.0.10",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"bit-buffer": "^0.3.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.19",
|
||||
"downshift": "^9.0.13",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^21.0.0",
|
||||
"ioredis": "^5.7.0",
|
||||
"file-type": "^21.3.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"next": "16.0.0-beta.0",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-webcam": "^7.2.0",
|
||||
"satori": "^0.18.2",
|
||||
"redis": "^5.10.0",
|
||||
"satori": "^0.19.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sharp": "^0.34.3",
|
||||
"sharp": "^0.34.5",
|
||||
"sjcl-with-all": "1.0.8",
|
||||
"swr": "^2.3.6",
|
||||
"zod": "^4.1.8"
|
||||
"swr": "^2.3.8",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@iconify/react": "^6.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "16.0.0-beta.0",
|
||||
"prisma": "^6.16.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prisma": "^6.19.2",
|
||||
"schema-dts": "^1.1.5",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.1"
|
||||
}
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4685
pnpm-lock.yaml
4685
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ const submitSchema = z
|
|||
{
|
||||
message: "Access key, gender, and Mii portrait image is required for Switch",
|
||||
path: ["accessKey", "gender", "miiPortraitImage"],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -102,17 +102,29 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const data = parsed.data;
|
||||
const {
|
||||
platform,
|
||||
name: uncensoredName,
|
||||
tags: uncensoredTags,
|
||||
description: uncensoredDescription,
|
||||
qrBytesRaw,
|
||||
accessKey,
|
||||
gender,
|
||||
miiPortraitImage,
|
||||
image1,
|
||||
image2,
|
||||
image3,
|
||||
} = parsed.data;
|
||||
|
||||
// Censor potential inappropriate words
|
||||
const name = profanity.censor(data.name);
|
||||
const tags = data.tags.map((t) => profanity.censor(t));
|
||||
const description = data.description && profanity.censor(data.description);
|
||||
const name = profanity.censor(uncensoredName);
|
||||
const tags = uncensoredTags.map((t) => profanity.censor(t));
|
||||
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
|
||||
|
||||
// Validate image files
|
||||
const customImages: File[] = [];
|
||||
|
||||
for (const img of [data.image1, data.image2, data.image3]) {
|
||||
for (const img of [image1, image2, image3]) {
|
||||
if (!img) continue;
|
||||
|
||||
const imageValidation = await validateImage(img);
|
||||
|
|
@ -124,16 +136,16 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Check Mii portrait image as well (Switch)
|
||||
if (data.platform === "SWITCH") {
|
||||
const imageValidation = await validateImage(data.miiPortraitImage);
|
||||
if (platform === "SWITCH") {
|
||||
const imageValidation = await validateImage(miiPortraitImage);
|
||||
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
|
||||
}
|
||||
|
||||
const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
|
||||
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
|
||||
|
||||
// Convert QR code to JS (3DS)
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
|
||||
if (data.platform === "THREE_DS") {
|
||||
if (platform === "THREE_DS") {
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
|
|
@ -145,17 +157,17 @@ export async function POST(request: NextRequest) {
|
|||
const miiRecord = await prisma.mii.create({
|
||||
data: {
|
||||
userId: Number(session.user.id),
|
||||
platform: data.platform,
|
||||
platform,
|
||||
name,
|
||||
tags,
|
||||
description,
|
||||
gender: data.gender ?? "MALE",
|
||||
gender: gender ?? "MALE",
|
||||
|
||||
// Access key only for Switch
|
||||
accessKey: data.platform === "SWITCH" ? data.accessKey : null,
|
||||
accessKey: platform === "SWITCH" ? accessKey : null,
|
||||
|
||||
// Automatically detect certain information if on 3DS
|
||||
...(data.platform === "THREE_DS" &&
|
||||
...(platform === "THREE_DS" &&
|
||||
conversion && {
|
||||
firstName: conversion.tomodachiLifeMii.firstName,
|
||||
lastName: conversion.tomodachiLifeMii.lastName,
|
||||
|
|
@ -174,7 +186,7 @@ export async function POST(request: NextRequest) {
|
|||
let portraitBuffer: Buffer | undefined;
|
||||
|
||||
// Download the image of the Mii (3DS)
|
||||
if (data.platform === "THREE_DS") {
|
||||
if (platform === "THREE_DS") {
|
||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||
const studioResponse = await fetch(studioUrl!);
|
||||
|
||||
|
|
@ -183,8 +195,8 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
|
||||
} else if (data.platform === "SWITCH") {
|
||||
portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer());
|
||||
} else if (platform === "SWITCH") {
|
||||
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
|
||||
}
|
||||
|
||||
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
|
||||
|
|
@ -200,7 +212,7 @@ export async function POST(request: NextRequest) {
|
|||
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
|
||||
}
|
||||
|
||||
if (data.platform === "THREE_DS") {
|
||||
if (platform === "THREE_DS") {
|
||||
try {
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
|
|
@ -235,7 +247,7 @@ export async function POST(request: NextRequest) {
|
|||
{
|
||||
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
|
||||
},
|
||||
500
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +260,7 @@ export async function POST(request: NextRequest) {
|
|||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
||||
|
||||
await fs.writeFile(fileLocation, webpBuffer);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Update database to tell it how many images exist
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ body {
|
|||
--color1: var(--color-amber-50);
|
||||
--color2: var(--color-amber-100);
|
||||
|
||||
background-image: repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)),
|
||||
background-image:
|
||||
repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)),
|
||||
repeating-linear-gradient(45deg, var(--color1) 25%, var(--color2) 25%, var(--color2) 75%, var(--color1) 75%, var(--color1));
|
||||
background-position: 0 0, 10px 10px;
|
||||
background-position:
|
||||
0 0,
|
||||
10px 10px;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
|
|
@ -41,14 +44,17 @@ body {
|
|||
|
||||
.button:disabled {
|
||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
||||
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
|
|
@ -64,7 +70,13 @@ body {
|
|||
@apply block;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.checkbox-alt {
|
||||
@apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all
|
||||
after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5
|
||||
after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px]
|
||||
checked:hover:bg-orange-500 ml-auto;
|
||||
}
|
||||
|
||||
[data-tooltip] {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
|
@ -82,23 +94,6 @@ body {
|
|||
@apply opacity-100 scale-100;
|
||||
}
|
||||
|
||||
/* Fallback Tooltips */
|
||||
[data-tooltip-span] {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
[data-tooltip-span] > .tooltip {
|
||||
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
|
||||
}
|
||||
|
||||
[data-tooltip-span] > .tooltip::before {
|
||||
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
|
||||
}
|
||||
|
||||
[data-tooltip-span]:hover > .tooltip {
|
||||
@apply opacity-100 scale-100;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
/* Firefox */
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ export default async function LoginPage() {
|
|||
</div>
|
||||
|
||||
<LoginButtons />
|
||||
|
||||
<p className="mt-8 text-xs text-zinc-400">
|
||||
By signing up, you agree to the{" "}
|
||||
<a href="/terms-of-service" className="underline hover:text-zinc-600">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className="underline hover:text-zinc-600">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,50 +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, 3, "/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,
|
||||
},
|
||||
},
|
||||
platform: 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -239,6 +239,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
||||
{/* Like button */}
|
||||
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
||||
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
||||
</div>
|
||||
{/* Tags */}
|
||||
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import Countdown from "@/components/countdown";
|
||||
import MiiList from "@/components/mii-list";
|
||||
import Skeleton from "@/components/mii-list/skeleton";
|
||||
|
||||
|
|
@ -35,7 +37,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
|||
|
||||
export default async function Page({ searchParams }: Props) {
|
||||
const session = await auth();
|
||||
const { tags } = await searchParams;
|
||||
const { page, tags } = await searchParams;
|
||||
|
||||
if (session?.user && !session.user.username) {
|
||||
redirect("/create-username");
|
||||
|
|
@ -54,6 +56,21 @@ export default async function Page({ searchParams }: Props) {
|
|||
<>
|
||||
<h1 className="sr-only">{tags ? `Miis tagged with '${tags}' - TomodachiShare` : "TomodachiShare - index mii list"}</h1>
|
||||
|
||||
{(!page || page === "1") && (
|
||||
<div className="flex items-center justify-center gap-2 mb-2 max-sm:flex-col">
|
||||
<a
|
||||
href="https://discord.gg/48cXBFKvWQ"
|
||||
className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-4 py-2.5 flex justify-center items-center gap-4 w-fit"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" fontSize={48} className="text-indigo-400" />
|
||||
<div>
|
||||
<p className="text-xl font-bold">Join the Discord</p>
|
||||
<p className="text-sm">Code: 48cXBFKvWQ</p>
|
||||
</div>
|
||||
</a>
|
||||
<Countdown />
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<MiiList searchParams={await searchParams} />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -32,8 +32,13 @@ export default async function SubmitPage() {
|
|||
if (activePunishment) redirect("/off-the-island");
|
||||
|
||||
// Check if submissions are disabled
|
||||
let value: boolean | null = true;
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||
const { value } = await response.json();
|
||||
value = await response.json();
|
||||
} catch (error) {
|
||||
return <p>An error occurred!</p>;
|
||||
}
|
||||
|
||||
if (!value)
|
||||
return (
|
||||
|
|
|
|||
63
src/components/countdown.tsx
Normal file
63
src/components/countdown.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Countdown() {
|
||||
const [days, setDays] = useState(31);
|
||||
const [hours, setHours] = useState(59);
|
||||
const [minutes, setMinutes] = useState(59);
|
||||
const [seconds, setSeconds] = useState(59);
|
||||
|
||||
const targetDate = new Date("2026-04-16T00:00:00Z").getTime();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const distance = targetDate - now;
|
||||
|
||||
if (distance < 0) {
|
||||
clearInterval(interval);
|
||||
setDays(0);
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
setSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setDays(Math.floor(distance / (1000 * 60 * 60 * 24)));
|
||||
setHours(Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
|
||||
setMinutes(Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)));
|
||||
setSeconds(Math.floor((distance % (1000 * 60)) / 1000));
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-4 py-2.5 flex justify-center items-center gap-8 w-fit max-sm:max-w-72 max-sm:w-full max-sm:flex-col max-sm:gap-2">
|
||||
<div className="flex flex-col max-sm:items-center">
|
||||
<h1 className="text-xl font-bold">Living the Dream</h1>
|
||||
<h2 className="text-right text-sm max-sm:text-center">releases in:</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{days}</span>
|
||||
<span className="text-xs">days</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{hours}</span>
|
||||
<span className="text-xs">hours</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{minutes}</span>
|
||||
<span className="text-xs">minutes</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{seconds}</span>
|
||||
<span className="text-xs">seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel();
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index);
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
|
|
@ -80,83 +80,74 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
<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"
|
||||
}`}
|
||||
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl mx-4 shadow-lg aspect-square w-full max-w-xl relative transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! aspect-square text-2xl absolute top-4 right-4 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<div className="z-50 absolute right-0 bg-amber-500 rounded-tr-xl rounded-bl-md p-1 flex justify-between items-center">
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
|
||||
<div className="flex h-full items-center">
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={576}
|
||||
height={576}
|
||||
className="object-contain"
|
||||
width={896}
|
||||
height={896}
|
||||
priority={index === selectedIndex}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length != 0 && (
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`z-50 absolute left-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
|
||||
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!emblaApi?.canScrollPrev()}
|
||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Next button */}
|
||||
<div
|
||||
className={`z-50 absolute right-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!emblaApi?.canScrollNext()}
|
||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`z-50 flex justify-center gap-3 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
className={`flex justify-center gap-2 bg-orange-300/25 p-2.5 rounded-full border border-orange-300 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -165,14 +156,14 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-orange-300 w-8" : "bg-orange-300/40"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Icon, loadIcons } from "@iconify/react";
|
||||
import { abbreviateNumber } from "@/lib/abbreviation";
|
||||
|
||||
|
|
@ -16,13 +16,18 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isLikedState, setIsLikedState] = useState(isLiked);
|
||||
const [likesState, setLikesState] = useState(likes);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const onClick = async () => {
|
||||
if (disabled) return;
|
||||
if (!isLoggedIn) redirect("/login");
|
||||
if (!isLoggedIn) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLikedState(!isLikedState);
|
||||
setLikesState(isLikedState ? likesState - 1 : likesState + 1);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { useSearchParams } from "next/navigation";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
import { MiiGender } from "@prisma/client";
|
||||
|
||||
import TagFilter from "./tag-filter";
|
||||
import PlatformSelect from "./platform-select";
|
||||
import GenderSelect from "./gender-select";
|
||||
import OtherFilters from "./other-filters";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -17,8 +17,9 @@ export default function FilterMenu() {
|
|||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const rawExclude = searchParams.get("exclude") || "";
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
|
|
@ -28,7 +29,17 @@ export default function FilterMenu() {
|
|||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags]
|
||||
[rawTags],
|
||||
);
|
||||
const exclude = useMemo(
|
||||
() =>
|
||||
rawExclude
|
||||
? rawExclude
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawExclude],
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
|
@ -49,45 +60,56 @@ export default function FilterMenu() {
|
|||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length;
|
||||
if (platform) count++;
|
||||
let count = tags.length + exclude.length;
|
||||
if (gender) count++;
|
||||
if (allowCopying) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, platform, gender]);
|
||||
}, [tags, exclude, gender, allowCopying]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
|
||||
Filter
|
||||
<span className="w-5">({filterCount})</span>
|
||||
</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"
|
||||
}`}
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-40 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>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Include</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</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 className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Exclude</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
<TagFilter isExclude />
|
||||
|
||||
<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" />
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Other</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<OtherFilters />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function GenderSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ import { searchSchema } from "@/lib/schemas";
|
|||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import FilterMenu from "./filter-menu";
|
||||
import SortSelect from "./sort-select";
|
||||
import Carousel from "../carousel";
|
||||
import LikeButton from "../like-button";
|
||||
import DeleteMiiButton from "../delete-mii";
|
||||
import Pagination from "./pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
|
||||
interface Props {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
|
|
@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
|
||||
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
|
||||
const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
|
||||
|
||||
// My Likes page
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
|
@ -51,10 +51,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Allow Copying
|
||||
...(allowCopying && { allowedCopying: true }),
|
||||
// Profiles
|
||||
...(userId && { userId }),
|
||||
};
|
||||
|
|
@ -76,6 +79,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
tags: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
allowedCopying: true,
|
||||
// Mii liked check
|
||||
...(session?.user?.id && {
|
||||
likedBy: {
|
||||
|
|
@ -96,9 +100,6 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
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,
|
||||
|
|
@ -106,10 +107,12 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
});
|
||||
|
||||
totalCount = matchingIds.length;
|
||||
filteredCount = Math.min(matchingIds.length, limit);
|
||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||
|
||||
if (matchingIds.length === 0) return;
|
||||
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
|
|
@ -119,7 +122,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
}
|
||||
|
||||
// Convert to number[] array
|
||||
const selectedIds = matchingIds.slice(0, limit).map((i) => i.id);
|
||||
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
list = await prisma.mii.findMany({
|
||||
where: {
|
||||
|
|
@ -194,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
<Carousel
|
||||
images={[
|
||||
`/mii/${mii.id}/image?type=mii`,
|
||||
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
|
||||
`/mii/${mii.id}/image?type=qr-code`,
|
||||
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
38
src/components/mii-list/other-filters.tsx
Normal file
38
src/components/mii-list/other-filters.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { ChangeEvent, ChangeEventHandler, useState, useTransition } from "react";
|
||||
|
||||
export default function OtherFilters() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!allowCopying) {
|
||||
params.set("allowCopying", "true");
|
||||
} else {
|
||||
params.delete("allowCopying");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
Allow Copying
|
||||
</label>
|
||||
<input type="checkbox" name="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export default function SortSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -54,11 +54,7 @@ export default function SortSelect() {
|
|||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import TagSelector from "../tag-selector";
|
||||
|
||||
export default function TagFilter() {
|
||||
interface Props {
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ isExclude }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
const preexistingTags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
|
|
@ -18,7 +22,7 @@ export default function TagFilter() {
|
|||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags]
|
||||
[rawTags],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
|
@ -39,20 +43,20 @@ export default function TagFilter() {
|
|||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set("tags", stateTags);
|
||||
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||
} else {
|
||||
params.delete("tags");
|
||||
params.delete(isExclude ? "exclude" : "tags");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { redirect, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { querySchema } from "@/lib/schemas";
|
||||
|
||||
export default function SearchBar() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) redirect("/");
|
||||
if (!result.success) {
|
||||
router.push("/", { scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone current search params and add query param
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("q", query);
|
||||
params.set("page", "1");
|
||||
|
||||
redirect(`/?${params.toString()}`);
|
||||
router.push(`/?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function SubmitForm() {
|
|||
if (files.length >= 3) return;
|
||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
},
|
||||
[files.length]
|
||||
[files.length],
|
||||
);
|
||||
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
|
|
@ -104,7 +104,7 @@ export default function SubmitForm() {
|
|||
const { id, error } = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(error);
|
||||
setError(String(error)); // app can crash if error message is not a string
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import jsQR from "jsqr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
|
|
@ -17,14 +16,12 @@ interface Props {
|
|||
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const requestRef = useRef<number>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
|
|
@ -42,8 +39,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
selectedItem,
|
||||
} = useSelect({
|
||||
items: cameraItems,
|
||||
selectedItem:
|
||||
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||
},
|
||||
|
|
@ -55,12 +51,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
// Continue scanning in a loop
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
|
||||
const webcam = webcamRef.current;
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!webcam || !canvas) return;
|
||||
|
||||
const video = webcam.video;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
|
@ -69,14 +62,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
video.videoWidth,
|
||||
video.videoHeight
|
||||
);
|
||||
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
if (!code) return;
|
||||
if (!code || !code.binaryData) return;
|
||||
|
||||
// Cancel animation frame to stop scanning
|
||||
if (requestRef.current) {
|
||||
|
|
@ -84,15 +72,20 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
requestRef.current = null;
|
||||
}
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
setQrBytesRaw(code.binaryData);
|
||||
setIsOpen(false);
|
||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||
|
||||
const requestPermission = async () => {
|
||||
const requestPermission = () => {
|
||||
if (!navigator.mediaDevices) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.getUserMedia({ video: true, audio: false })
|
||||
.then(() => setPermissionGranted(true))
|
||||
.catch(() => setPermissionGranted(false));
|
||||
.catch((err) => {
|
||||
setPermissionGranted(false);
|
||||
console.error("An error occurred trying to access the camera", err);
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
|
|
@ -106,34 +99,50 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
requestPermission();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
requestPermission();
|
||||
if (!isOpen || !permissionGranted) return;
|
||||
|
||||
if (!navigator.mediaDevices.enumerateDevices) return;
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((devices) => {
|
||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
if (!selectedDeviceId && videoDevices.length > 0) {
|
||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
||||
}
|
||||
});
|
||||
}, [isOpen, selectedDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !permissionGranted) return;
|
||||
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
||||
if (!targetDeviceId) return;
|
||||
setSelectedDeviceId(targetDeviceId);
|
||||
|
||||
// start camera stream
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: targetDeviceId },
|
||||
audio: false,
|
||||
});
|
||||
})
|
||||
.then((stream) => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.play();
|
||||
})
|
||||
.catch((err) => console.error("Camera error", err));
|
||||
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
if (videoRef.current?.srcObject) {
|
||||
const stream = videoRef.current.srcObject as MediaStream;
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, permissionGranted, scanQRCode]);
|
||||
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
|
@ -141,9 +150,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
<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"
|
||||
}`}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -153,12 +160,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -191,9 +193,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
||||
highlightedIndex === index ? "bg-black/15" : ""
|
||||
}`}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
|
|
@ -204,51 +204,19 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
)}
|
||||
|
||||
<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!"
|
||||
>
|
||||
{!permissionGranted && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 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"
|
||||
/>
|
||||
)}
|
||||
|
||||
<video ref={videoRef} 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">
|
||||
|
|
|
|||
|
|
@ -8,32 +8,18 @@ interface Props {
|
|||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
showTagLimit?: boolean;
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = [
|
||||
"anime",
|
||||
"art",
|
||||
"cartoon",
|
||||
"celebrity",
|
||||
"games",
|
||||
"history",
|
||||
"meme",
|
||||
"movie",
|
||||
"oc",
|
||||
"tv",
|
||||
];
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: 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));
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
|
|
@ -49,37 +35,39 @@ 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, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
selectedItem: null,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
if (inputValue && !tagRegex.test(inputValue)) return;
|
||||
setInputValue(inputValue || "");
|
||||
const newValue = inputValue || "";
|
||||
if (newValue && !tagRegex.test(newValue)) return;
|
||||
setInputValue(newValue);
|
||||
},
|
||||
onStateChange: ({ type, selectedItem }) => {
|
||||
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
stateReducer: (_, { type, changes }) => {
|
||||
// Prevent input from being filled when item is selected
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick) {
|
||||
return {
|
||||
...changes,
|
||||
inputValue: "",
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
} else if (event.key === "Backspace" && inputValue === "") {
|
||||
// Spill onto last tag
|
||||
if (event.key === "Backspace" && inputValue === "") {
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
|
|
@ -104,10 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
{/* 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"
|
||||
>
|
||||
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -137,17 +122,9 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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([])}
|
||||
>
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -176,9 +153,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
||||
highlightedIndex === index ? "bg-black/15" : ""
|
||||
}`}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
|
|
@ -202,9 +177,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
{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-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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Stolen from https://github.com/PretendoNetwork/mii-js/
|
||||
// Based on https://github.com/PretendoNetwork/mii-js/
|
||||
// Updated to bit-buffer v0.3.0
|
||||
|
||||
import { BitStream } from "bit-buffer";
|
||||
|
||||
|
|
@ -11,78 +12,34 @@ export default class ExtendedBitStream extends BitStream {
|
|||
this.bigEndian = !this.bigEndian;
|
||||
}
|
||||
|
||||
// the type definition for BitStream does not include the _index property
|
||||
// since it's supposed to be private, but it's needed 4 times here sooo
|
||||
|
||||
public alignByte(): void {
|
||||
// @ts-expect-error _index is private
|
||||
const nextClosestByteIndex = 8 * Math.ceil(this._index / 8);
|
||||
// @ts-expect-error _index is private
|
||||
const bitDistance = nextClosestByteIndex - this._index;
|
||||
|
||||
const nextClosestByteIndex = 8 * Math.ceil(this.index / 8);
|
||||
const bitDistance = nextClosestByteIndex - this.index;
|
||||
this.skipBits(bitDistance);
|
||||
}
|
||||
|
||||
public bitSeek(bitPos: number): void {
|
||||
// @ts-expect-error _index is private
|
||||
this._index = bitPos;
|
||||
}
|
||||
|
||||
public skipBits(bitCount: number): void {
|
||||
// @ts-expect-error _index is private
|
||||
this._index += bitCount;
|
||||
}
|
||||
|
||||
public skipBytes(bytes: number): void {
|
||||
const bits = bytes * 8;
|
||||
this.skipBits(bits);
|
||||
this.index += bitCount;
|
||||
}
|
||||
|
||||
public skipBit(): void {
|
||||
this.skipBits(1);
|
||||
}
|
||||
|
||||
public skipInt8(): void {
|
||||
this.skipBytes(1);
|
||||
}
|
||||
|
||||
public skipInt16(): void {
|
||||
// Skipping a uint16 is the same as skipping 2 uint8's
|
||||
this.skipBytes(2);
|
||||
this.skipBits(16);
|
||||
}
|
||||
|
||||
public readBit(): number {
|
||||
return this.readBits(1);
|
||||
}
|
||||
|
||||
public readBytes(length: number): number[] {
|
||||
return Array(length)
|
||||
.fill(0)
|
||||
.map(() => this.readUint8());
|
||||
return this.readBits(1, false);
|
||||
}
|
||||
|
||||
public readBuffer(length: number): Buffer {
|
||||
return Buffer.from(this.readBytes(length));
|
||||
return Buffer.from(super.readBytes(length));
|
||||
}
|
||||
|
||||
public readUTF16String(length: number): string {
|
||||
return this.readBuffer(length).toString("utf16le").replace(/\0.*$/, "");
|
||||
}
|
||||
|
||||
public writeBit(bit: number): void {
|
||||
this.writeBits(bit, 1);
|
||||
}
|
||||
|
||||
public writeBuffer(buffer: Buffer): void {
|
||||
buffer.forEach((byte) => this.writeUint8(byte));
|
||||
}
|
||||
|
||||
public writeUTF16String(string: string): void {
|
||||
const stringBuffer = Buffer.from(string, "utf16le");
|
||||
const terminatedBuffer = Buffer.alloc(0x14);
|
||||
|
||||
stringBuffer.copy(terminatedBuffer);
|
||||
|
||||
this.writeBuffer(terminatedBuffer);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,14 @@ const STUDIO_RENDER_CLOTHES_COLORS = [
|
|||
"black",
|
||||
];
|
||||
|
||||
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
|
||||
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = [
|
||||
"none",
|
||||
"zerox",
|
||||
"flipx",
|
||||
"camera",
|
||||
"offset",
|
||||
"set",
|
||||
];
|
||||
|
||||
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
|
||||
|
||||
|
|
@ -74,6 +81,7 @@ const STUDIO_BG_COLOR_REGEX = /^[0-9A-F]{8}$/; // Mii Studio does not allow lowe
|
|||
|
||||
export default class Mii {
|
||||
public bitStream: ExtendedBitStream;
|
||||
public buffer: Buffer;
|
||||
|
||||
// Mii data
|
||||
// can be sure that these are all initialized in decode()
|
||||
|
|
@ -150,92 +158,292 @@ export default class Mii {
|
|||
public checksum!: number;
|
||||
|
||||
constructor(buffer: Buffer) {
|
||||
this.buffer = buffer;
|
||||
this.bitStream = new ExtendedBitStream(buffer);
|
||||
this.decode();
|
||||
}
|
||||
|
||||
public validate(): void {
|
||||
// Size check
|
||||
assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
|
||||
assert.equal(
|
||||
this.bitStream.length / 8,
|
||||
0x60,
|
||||
`Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`,
|
||||
);
|
||||
|
||||
// Value range and type checks
|
||||
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
|
||||
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
|
||||
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.regionLock, Util.range(4)), `Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`);
|
||||
assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
|
||||
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
|
||||
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
|
||||
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
|
||||
assert.ok(Util.inRange(this.deviceOrigin, Util.range(1, 5)), `Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`);
|
||||
assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
|
||||
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
|
||||
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
|
||||
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
|
||||
assert.equal(typeof this.isValid, "boolean", `Invalid Mii valid flag. Got ${this.isValid}, expected true or false`);
|
||||
assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
|
||||
assert.ok(
|
||||
this.version === 0 || this.version === 3,
|
||||
`Invalid Mii version. Got ${this.version}, expected 0 or 3`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.allowCopying,
|
||||
"boolean",
|
||||
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.profanityFlag,
|
||||
"boolean",
|
||||
`Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.regionLock, Util.range(4)),
|
||||
`Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.characterSet, Util.range(4)),
|
||||
`Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.pageIndex, Util.range(10)),
|
||||
`Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.slotIndex, Util.range(10)),
|
||||
`Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`,
|
||||
);
|
||||
assert.equal(
|
||||
this.unknown1,
|
||||
0,
|
||||
`Invalid Mii unknown1. Got ${this.unknown1}, expected 0`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.deviceOrigin, Util.range(1, 5)),
|
||||
`Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`,
|
||||
);
|
||||
assert.equal(
|
||||
this.systemId.length,
|
||||
8,
|
||||
`Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.normalMii,
|
||||
"boolean",
|
||||
`Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.dsMii,
|
||||
"boolean",
|
||||
`Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.nonUserMii,
|
||||
"boolean",
|
||||
`Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.isValid,
|
||||
"boolean",
|
||||
`Invalid Mii valid flag. Got ${this.isValid}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
this.creationTime < 268435456,
|
||||
`Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`,
|
||||
);
|
||||
assert.equal(
|
||||
this.consoleMAC.length,
|
||||
6,
|
||||
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`
|
||||
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.gender, Util.range(2)),
|
||||
`Invalid Mii gender. Got ${this.gender}, expected 0 or 1`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.birthMonth, Util.range(13)),
|
||||
`Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.birthDay, Util.range(32)),
|
||||
`Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.favoriteColor, Util.range(12)),
|
||||
`Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.favorite,
|
||||
"boolean",
|
||||
`Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
Buffer.from(this.miiName, "utf16le").length <= 0x14,
|
||||
`Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.height, Util.range(128)),
|
||||
`Invalid Mii height. Got ${this.height}, expected 0-127`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.build, Util.range(128)),
|
||||
`Invalid Mii build. Got ${this.build}, expected 0-127`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.disableSharing,
|
||||
"boolean",
|
||||
`Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.faceType, Util.range(12)),
|
||||
`Invalid Mii face type. Got ${this.faceType}, expected 0-11`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.skinColor, Util.range(7)),
|
||||
`Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.wrinklesType, Util.range(12)),
|
||||
`Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.makeupType, Util.range(12)),
|
||||
`Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.hairType, Util.range(132)),
|
||||
`Invalid Mii hair type. Got ${this.hairType}, expected 0-131`,
|
||||
);
|
||||
assert.ok(Util.inRange(this.gender, Util.range(2)), `Invalid Mii gender. Got ${this.gender}, expected 0 or 1`);
|
||||
assert.ok(Util.inRange(this.birthMonth, Util.range(13)), `Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
|
||||
assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
|
||||
assert.equal(typeof this.favorite, "boolean", `Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`);
|
||||
assert.ok(Buffer.from(this.miiName, "utf16le").length <= 0x14, `Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`);
|
||||
assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
|
||||
assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
|
||||
assert.equal(typeof this.disableSharing, "boolean", `Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.faceType, Util.range(12)), `Invalid Mii face type. Got ${this.faceType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
|
||||
assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.makeupType, Util.range(12)), `Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.hairType, Util.range(132)), `Invalid Mii hair type. Got ${this.hairType}, expected 0-131`);
|
||||
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
|
||||
assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
|
||||
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyeVerticalStretch, Util.range(7)), `Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`);
|
||||
assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
|
||||
assert.equal(
|
||||
typeof this.flipHair,
|
||||
"boolean",
|
||||
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeType, Util.range(60)),
|
||||
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeColor, Util.range(6)),
|
||||
`Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeScale, Util.range(8)),
|
||||
`Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeVerticalStretch, Util.range(7)),
|
||||
`Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeRotation, Util.range(8)),
|
||||
`Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeSpacing, Util.range(13)),
|
||||
`Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyeYPosition, Util.range(19)),
|
||||
`Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowType, Util.range(25)),
|
||||
`Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`,
|
||||
);
|
||||
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowScale, Util.range(9)),
|
||||
`Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
|
||||
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`
|
||||
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowRotation, Util.range(12)),
|
||||
`Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowSpacing, Util.range(13)),
|
||||
`Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowYPosition, Util.range(3, 19)),
|
||||
`Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.noseType, Util.range(18)),
|
||||
`Invalid Mii nose type. Got ${this.noseType}, expected 0-17`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.noseScale, Util.range(9)),
|
||||
`Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.noseYPosition, Util.range(19)),
|
||||
`Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mouthType, Util.range(36)),
|
||||
`Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mouthColor, Util.range(5)),
|
||||
`Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mouthScale, Util.range(9)),
|
||||
`Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`,
|
||||
);
|
||||
assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), `Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`);
|
||||
assert.ok(Util.inRange(this.noseType, Util.range(18)), `Invalid Mii nose type. Got ${this.noseType}, expected 0-17`);
|
||||
assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.mouthType, Util.range(36)), `Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`);
|
||||
assert.ok(Util.inRange(this.mouthColor, Util.range(5)), `Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`);
|
||||
assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
|
||||
assert.ok(
|
||||
Util.inRange(this.mouthHorizontalStretch, Util.range(7)),
|
||||
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`
|
||||
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mouthYPosition, Util.range(19)),
|
||||
`Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mustacheType, Util.range(6)),
|
||||
`Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.beardType, Util.range(6)),
|
||||
`Invalid Mii beard type. Got ${this.beardType}, expected 0-5`,
|
||||
);
|
||||
assert.ok(Util.inRange(this.mouthYPosition, Util.range(19)), `Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.mustacheType, Util.range(6)), `Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
|
||||
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
|
||||
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.glassesColor, Util.range(6)), `Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
|
||||
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.moleScale, Util.range(9)), `Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
|
||||
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
|
||||
assert.ok(
|
||||
Util.inRange(this.mustacheScale, Util.range(9)),
|
||||
`Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.mustacheYPosition, Util.range(17)),
|
||||
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.glassesType, Util.range(9)),
|
||||
`Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.glassesColor, Util.range(6)),
|
||||
`Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.glassesScale, Util.range(8)),
|
||||
`Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.glassesYPosition, Util.range(21)),
|
||||
`Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof this.moleEnabled,
|
||||
"boolean",
|
||||
`Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.moleScale, Util.range(9)),
|
||||
`Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.moleXPosition, Util.range(17)),
|
||||
`Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`,
|
||||
);
|
||||
assert.ok(
|
||||
Util.inRange(this.moleYPosition, Util.range(31)),
|
||||
`Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`,
|
||||
);
|
||||
|
||||
// Sanity checks
|
||||
/*
|
||||
|
|
@ -251,7 +459,10 @@ export default class Mii {
|
|||
}
|
||||
*/
|
||||
|
||||
if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
|
||||
if (
|
||||
this.nonUserMii &&
|
||||
(this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)
|
||||
) {
|
||||
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
|
||||
}
|
||||
|
||||
|
|
@ -357,10 +568,12 @@ export default class Mii {
|
|||
}
|
||||
|
||||
public calculateCRC(): number {
|
||||
const view = this.bitStream.view;
|
||||
|
||||
// @ts-expect-error _view is private
|
||||
const data = view._view.subarray(0, 0x5e);
|
||||
// #view is inaccessible
|
||||
const data = new Uint8Array(
|
||||
this.buffer.buffer,
|
||||
this.buffer.byteOffset,
|
||||
this.buffer.length,
|
||||
).subarray(0, 0x5e);
|
||||
|
||||
let crc = 0x0000;
|
||||
|
||||
|
|
@ -506,7 +719,7 @@ export default class Mii {
|
|||
instanceCount?: number;
|
||||
instanceRotationMode?: string;
|
||||
data?: string;
|
||||
} = STUDIO_RENDER_DEFAULTS
|
||||
} = STUDIO_RENDER_DEFAULTS,
|
||||
): string {
|
||||
const params = {
|
||||
...STUDIO_RENDER_DEFAULTS,
|
||||
|
|
@ -514,11 +727,23 @@ export default class Mii {
|
|||
data: this.encodeStudio().toString("hex"),
|
||||
};
|
||||
|
||||
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
|
||||
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
|
||||
params.type = STUDIO_RENDER_TYPES.includes(params.type as string)
|
||||
? params.type
|
||||
: STUDIO_RENDER_DEFAULTS.type;
|
||||
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(
|
||||
params.expression as string,
|
||||
)
|
||||
? params.expression
|
||||
: STUDIO_RENDER_DEFAULTS.expression;
|
||||
params.width = Util.clamp(params.width, 512);
|
||||
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
|
||||
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
|
||||
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string)
|
||||
? params.bgColor
|
||||
: STUDIO_RENDER_DEFAULTS.bgColor;
|
||||
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(
|
||||
params.clothesColor,
|
||||
)
|
||||
? params.clothesColor
|
||||
: STUDIO_RENDER_DEFAULTS.clothesColor;
|
||||
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
|
||||
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
||||
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
||||
|
|
@ -528,16 +753,25 @@ export default class Mii {
|
|||
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
||||
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
||||
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
|
||||
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
|
||||
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(
|
||||
params.lightDirectionMode,
|
||||
)
|
||||
? params.lightDirectionMode
|
||||
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
||||
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
|
||||
params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
|
||||
params.instanceRotationMode =
|
||||
STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
|
||||
params.instanceRotationMode,
|
||||
)
|
||||
? params.instanceRotationMode
|
||||
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
||||
|
||||
// converts non-string params to strings
|
||||
const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
|
||||
const query = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, value.toString()]),
|
||||
),
|
||||
);
|
||||
|
||||
if (params.lightDirectionMode === "none") {
|
||||
query.delete("lightDirectionMode");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Redis } from "ioredis";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { auth } from "./auth";
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL!);
|
||||
const windowSize = 60;
|
||||
const WINDOW_SIZE = 60;
|
||||
let client: RedisClientType | null = null;
|
||||
|
||||
interface RateLimitData {
|
||||
success: boolean;
|
||||
|
|
@ -12,6 +12,17 @@ interface RateLimitData {
|
|||
expires: number;
|
||||
}
|
||||
|
||||
async function getRedisClient() {
|
||||
if (!client) {
|
||||
client = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
client.on("error", (err) => console.error("Redis client error", err));
|
||||
await client.connect();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// Fixed window implementation
|
||||
export class RateLimit {
|
||||
private request: NextRequest;
|
||||
|
|
@ -37,22 +48,19 @@ export class RateLimit {
|
|||
|
||||
const now = Date.now();
|
||||
const seconds = Math.floor(now / 1000);
|
||||
const currentWindow = Math.floor(seconds / windowSize) * windowSize;
|
||||
const expireAt = currentWindow + windowSize;
|
||||
const currentWindow = Math.floor(seconds / WINDOW_SIZE) * WINDOW_SIZE;
|
||||
const expireAt = currentWindow + WINDOW_SIZE;
|
||||
|
||||
try {
|
||||
// Create a Redis transaction
|
||||
const tx = redis.multi();
|
||||
tx.incr(key);
|
||||
tx.expireat(key, expireAt);
|
||||
const client = await getRedisClient();
|
||||
|
||||
// Execute transaction and get the count
|
||||
const results = await tx.exec();
|
||||
if (!results) {
|
||||
// Execute a Redis transaction and get the count
|
||||
const [result] = await client.multi().incr(key).expireAt(key, expireAt).exec();
|
||||
if (!result) {
|
||||
throw new Error("Redis transaction failed");
|
||||
}
|
||||
|
||||
const count = results[0][1] as number;
|
||||
const count = result as unknown as number;
|
||||
const success = count <= this.maxRequests;
|
||||
const remaining = Math.max(0, this.maxRequests - count);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const tagsSchema = z
|
|||
.max(20, { error: "Tags cannot be more than 20 characters long" })
|
||||
.regex(/^[a-z0-9-_]+$/, {
|
||||
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
.min(1, { error: "There must be at least 1 tag" })
|
||||
.max(8, { error: "There cannot be more than 8 tags" });
|
||||
|
|
@ -47,10 +47,20 @@ export const searchSchema = z.object({
|
|||
value
|
||||
?.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
exclude: 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(),
|
||||
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
||||
// todo: incorporate tagsSchema
|
||||
// Pages
|
||||
limit: z.coerce
|
||||
|
|
|
|||
Loading…
Reference in a new issue