mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
Merge branch 'main' into feat/living-the-dream-qr-code
This commit is contained in:
commit
118739041f
30 changed files with 1596 additions and 1524 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,6 +16,7 @@
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
certificates/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/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
|
## 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
|
- 📷 Upload or scan your Mii QR codes
|
||||||
- ✨ Generates Mii renders for previews
|
- ✨ Generates Mii renders for previews
|
||||||
|
|
@ -27,8 +27,6 @@
|
||||||
|
|
||||||
### <a href="/DEVELOPMENT.md">Development Instructions</a>
|
### <a href="/DEVELOPMENT.md">Development Instructions</a>
|
||||||
|
|
||||||
### <a href="/API.md">API Reference</a>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
33
package.json
33
package.json
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "tomodachi-share",
|
"name": "tomodachi-share",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.28.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|
@ -16,45 +16,44 @@
|
||||||
"@auth/prisma-adapter": "2.11.1",
|
"@auth/prisma-adapter": "2.11.1",
|
||||||
"@bprogress/next": "^3.2.12",
|
"@bprogress/next": "^3.2.12",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/client": "^6.19.1",
|
"@prisma/client": "^6.19.2",
|
||||||
"bit-buffer": "^0.2.5",
|
"bit-buffer": "^0.3.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"downshift": "^9.0.13",
|
"downshift": "^9.0.13",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"file-type": "^21.1.1",
|
"file-type": "^21.3.0",
|
||||||
"ioredis": "^5.8.2",
|
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
"next": "16.0.10",
|
"next": "16.1.6",
|
||||||
"next-auth": "5.0.0-beta.30",
|
"next-auth": "5.0.0-beta.30",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-webcam": "^7.2.0",
|
"redis": "^5.10.0",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.19.1",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sjcl-with-all": "1.0.8",
|
"sjcl-with-all": "1.0.8",
|
||||||
"swr": "^2.3.7",
|
"swr": "^2.3.8",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^25.0.2",
|
"@types/node": "^25.1.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/seedrandom": "^3.0.8",
|
"@types/seedrandom": "^3.0.8",
|
||||||
"@types/sjcl": "^1.0.34",
|
"@types/sjcl": "^1.0.34",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.1.6",
|
||||||
"prisma": "^6.19.1",
|
"prisma": "^6.19.2",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.15"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1532
pnpm-lock.yaml
1532
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,7 +54,7 @@ const submitSchema = z
|
||||||
{
|
{
|
||||||
message: "Gender and Mii portrait image are required for Switch platform",
|
message: "Gender and Mii portrait image are required for Switch platform",
|
||||||
path: ["gender", "miiPortraitImage"],
|
path: ["gender", "miiPortraitImage"],
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -99,17 +99,28 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||||
const data = parsed.data;
|
const {
|
||||||
|
platform,
|
||||||
|
name: uncensoredName,
|
||||||
|
tags: uncensoredTags,
|
||||||
|
description: uncensoredDescription,
|
||||||
|
qrBytesRaw,
|
||||||
|
gender,
|
||||||
|
miiPortraitImage,
|
||||||
|
image1,
|
||||||
|
image2,
|
||||||
|
image3,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
// Censor potential inappropriate words
|
// Censor potential inappropriate words
|
||||||
const name = profanity.censor(data.name);
|
const name = profanity.censor(uncensoredName);
|
||||||
const tags = data.tags.map((t) => profanity.censor(t));
|
const tags = uncensoredTags.map((t) => profanity.censor(t));
|
||||||
const description = data.description && profanity.censor(data.description);
|
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
|
||||||
|
|
||||||
// Validate image files
|
// Validate image files
|
||||||
const customImages: File[] = [];
|
const customImages: File[] = [];
|
||||||
|
|
||||||
for (const img of [data.image1, data.image2, data.image3]) {
|
for (const img of [image1, image2, image3]) {
|
||||||
if (!img) continue;
|
if (!img) continue;
|
||||||
|
|
||||||
const imageValidation = await validateImage(img);
|
const imageValidation = await validateImage(img);
|
||||||
|
|
@ -121,16 +132,16 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Mii portrait image as well (Switch)
|
// Check Mii portrait image as well (Switch)
|
||||||
if (data.platform === "SWITCH") {
|
if (platform === "SWITCH") {
|
||||||
const imageValidation = await validateImage(data.miiPortraitImage);
|
const imageValidation = await validateImage(miiPortraitImage);
|
||||||
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
|
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)
|
// Convert QR code to JS (3DS)
|
||||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
|
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
|
||||||
if (data.platform === "THREE_DS") {
|
if (platform === "THREE_DS") {
|
||||||
try {
|
try {
|
||||||
conversion = convertQrCode(qrBytes);
|
conversion = convertQrCode(qrBytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -142,14 +153,14 @@ export async function POST(request: NextRequest) {
|
||||||
const miiRecord = await prisma.mii.create({
|
const miiRecord = await prisma.mii.create({
|
||||||
data: {
|
data: {
|
||||||
userId: Number(session.user.id),
|
userId: Number(session.user.id),
|
||||||
platform: data.platform,
|
platform,
|
||||||
name,
|
name,
|
||||||
tags,
|
tags,
|
||||||
description,
|
description,
|
||||||
gender: data.gender ?? "MALE",
|
gender: gender ?? "MALE",
|
||||||
|
|
||||||
// Automatically detect certain information if on 3DS
|
// Automatically detect certain information if on 3DS
|
||||||
...(data.platform === "THREE_DS" &&
|
...(platform === "THREE_DS" &&
|
||||||
conversion && {
|
conversion && {
|
||||||
firstName: conversion.tomodachiLifeMii.firstName,
|
firstName: conversion.tomodachiLifeMii.firstName,
|
||||||
lastName: conversion.tomodachiLifeMii.lastName,
|
lastName: conversion.tomodachiLifeMii.lastName,
|
||||||
|
|
@ -168,7 +179,7 @@ export async function POST(request: NextRequest) {
|
||||||
let portraitBuffer: Buffer | undefined;
|
let portraitBuffer: Buffer | undefined;
|
||||||
|
|
||||||
// Download the image of the Mii (3DS)
|
// Download the image of the Mii (3DS)
|
||||||
if (data.platform === "THREE_DS") {
|
if (platform === "THREE_DS") {
|
||||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||||
const studioResponse = await fetch(studioUrl!);
|
const studioResponse = await fetch(studioUrl!);
|
||||||
|
|
||||||
|
|
@ -177,8 +188,8 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
|
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
|
||||||
} else if (data.platform === "SWITCH") {
|
} else if (platform === "SWITCH") {
|
||||||
portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer());
|
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
|
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
|
||||||
|
|
@ -215,6 +226,8 @@ export async function POST(request: NextRequest) {
|
||||||
// Clean up if something went wrong
|
// Clean up if something went wrong
|
||||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||||
|
|
||||||
|
console.error("Error processing Mii files:", error);
|
||||||
|
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
||||||
console.error("Error generating QR code:", error);
|
console.error("Error generating QR code:", error);
|
||||||
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +240,7 @@ export async function POST(request: NextRequest) {
|
||||||
{
|
{
|
||||||
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
|
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
|
||||||
},
|
},
|
||||||
500
|
500,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,7 +253,7 @@ export async function POST(request: NextRequest) {
|
||||||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
||||||
|
|
||||||
await fs.writeFile(fileLocation, webpBuffer);
|
await fs.writeFile(fileLocation, webpBuffer);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update database to tell it how many images exist
|
// Update database to tell it how many images exist
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,12 @@ body {
|
||||||
--color1: var(--color-amber-50);
|
--color1: var(--color-amber-50);
|
||||||
--color2: var(--color-amber-100);
|
--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));
|
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;
|
background-size: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,14 +44,17 @@ body {
|
||||||
|
|
||||||
.button:disabled {
|
.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;
|
||||||
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.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;
|
||||||
|
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||||
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
|
|
@ -64,7 +70,13 @@ body {
|
||||||
@apply block;
|
@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] {
|
[data-tooltip] {
|
||||||
@apply relative z-10;
|
@apply relative z-10;
|
||||||
}
|
}
|
||||||
|
|
@ -82,23 +94,6 @@ body {
|
||||||
@apply opacity-100 scale-100;
|
@apply opacity-100 scale-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback Tooltips */
|
|
||||||
[data-tooltip-span] {
|
|
||||||
@apply relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-tooltip-span] > .tooltip {
|
|
||||||
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-tooltip-span] > .tooltip::before {
|
|
||||||
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-tooltip-span]:hover > .tooltip {
|
|
||||||
@apply opacity-100 scale-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbars */
|
/* Scrollbars */
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
* {
|
* {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,18 @@ export default async function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoginButtons />
|
<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>
|
||||||
</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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -235,6 +235,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
||||||
{/* Like button */}
|
{/* Like button */}
|
||||||
<LikeButton 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 />
|
||||||
|
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
||||||
</div>
|
</div>
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
<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 { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
import Countdown from "@/components/countdown";
|
||||||
import MiiList from "@/components/mii-list";
|
import MiiList from "@/components/mii-list";
|
||||||
import Skeleton from "@/components/mii-list/skeleton";
|
import Skeleton from "@/components/mii-list/skeleton";
|
||||||
|
|
||||||
|
|
@ -35,7 +37,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
||||||
|
|
||||||
export default async function Page({ searchParams }: Props) {
|
export default async function Page({ searchParams }: Props) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { tags } = await searchParams;
|
const { page, tags } = await searchParams;
|
||||||
|
|
||||||
if (session?.user && !session.user.username) {
|
if (session?.user && !session.user.username) {
|
||||||
redirect("/create-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>
|
<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 />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
<MiiList searchParams={await searchParams} />
|
<MiiList searchParams={await searchParams} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,13 @@ export default async function SubmitPage() {
|
||||||
if (activePunishment) redirect("/off-the-island");
|
if (activePunishment) redirect("/off-the-island");
|
||||||
|
|
||||||
// Check if submissions are disabled
|
// 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 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)
|
if (!value)
|
||||||
return (
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [isVisible, setIsVisible] = 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 [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
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
|
// Keep order of images whilst opening at src prop
|
||||||
const index = images.indexOf(src);
|
const index = images.indexOf(src);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
emblaApi.scrollTo(index);
|
emblaApi.scrollTo(index, true);
|
||||||
setSelectedIndex(index);
|
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 className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||||
<div
|
<div
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||||
isVisible ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
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 ${
|
type="button"
|
||||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
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" />
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
|
<div
|
||||||
<div className="flex h-full items-center">
|
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) => (
|
{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
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
width={576}
|
width={896}
|
||||||
height={576}
|
height={896}
|
||||||
className="object-contain"
|
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" }}
|
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{images.length != 0 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
{/* Carousel buttons */}
|
{/* Carousel counter */}
|
||||||
{/* Prev button */}
|
|
||||||
<div
|
<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"
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{selectedIndex + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carousel buttons */}
|
||||||
|
{/* Prev button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Scroll Carousel Left"
|
aria-label="Scroll Carousel Left"
|
||||||
onClick={() => emblaApi?.scrollPrev()}
|
onClick={() => emblaApi?.scrollPrev()}
|
||||||
disabled={!emblaApi?.canScrollPrev()}
|
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"}`}
|
||||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
|
||||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="ic:round-chevron-left" />
|
<Icon icon="ic:round-chevron-left" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{/* Next button */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Scroll Carousel Right"
|
aria-label="Scroll Carousel Right"
|
||||||
onClick={() => emblaApi?.scrollNext()}
|
onClick={() => emblaApi?.scrollNext()}
|
||||||
disabled={!emblaApi?.canScrollNext()}
|
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"}`}
|
||||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
|
||||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="ic:round-chevron-right" />
|
<Icon icon="ic:round-chevron-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Carousel snaps */}
|
{/* Carousel snaps */}
|
||||||
<div
|
<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"
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -165,14 +156,14 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
||||||
key={index}
|
key={index}
|
||||||
aria-label={`Go to ${index} in Carousel`}
|
aria-label={`Go to ${index} in Carousel`}
|
||||||
onClick={() => emblaApi?.scrollTo(index)}
|
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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Icon, loadIcons } from "@iconify/react";
|
import { Icon, loadIcons } from "@iconify/react";
|
||||||
import { abbreviateNumber } from "@/lib/abbreviation";
|
import { abbreviateNumber } from "@/lib/abbreviation";
|
||||||
|
|
||||||
|
|
@ -16,13 +16,18 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
|
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isLikedState, setIsLikedState] = useState(isLiked);
|
const [isLikedState, setIsLikedState] = useState(isLiked);
|
||||||
const [likesState, setLikesState] = useState(likes);
|
const [likesState, setLikesState] = useState(likes);
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (!isLoggedIn) redirect("/login");
|
if (!isLoggedIn) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLikedState(!isLikedState);
|
setIsLikedState(!isLikedState);
|
||||||
setLikesState(isLikedState ? likesState - 1 : likesState + 1);
|
setLikesState(isLikedState ? likesState - 1 : likesState + 1);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
import { MiiGender } from "@prisma/client";
|
||||||
|
|
||||||
import TagFilter from "./tag-filter";
|
import TagFilter from "./tag-filter";
|
||||||
import PlatformSelect from "./platform-select";
|
|
||||||
import GenderSelect from "./gender-select";
|
import GenderSelect from "./gender-select";
|
||||||
|
import OtherFilters from "./other-filters";
|
||||||
|
|
||||||
export default function FilterMenu() {
|
export default function FilterMenu() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -17,8 +17,9 @@ export default function FilterMenu() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const rawTags = searchParams.get("tags") || "";
|
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 gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||||
|
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||||
|
|
||||||
const tags = useMemo(
|
const tags = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -28,7 +29,17 @@ export default function FilterMenu() {
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter((tag) => tag.length > 0)
|
.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);
|
const [filterCount, setFilterCount] = useState(tags.length);
|
||||||
|
|
@ -49,45 +60,56 @@ export default function FilterMenu() {
|
||||||
|
|
||||||
// Count all active filters
|
// Count all active filters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let count = tags.length;
|
let count = tags.length + exclude.length;
|
||||||
if (platform) count++;
|
|
||||||
if (gender) count++;
|
if (gender) count++;
|
||||||
|
if (allowCopying) count++;
|
||||||
|
|
||||||
setFilterCount(count);
|
setFilterCount(count);
|
||||||
}, [tags, platform, gender]);
|
}, [tags, exclude, gender, allowCopying]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button className="pill button gap-2" onClick={handleClick}>
|
<button className="pill button gap-2" onClick={handleClick}>
|
||||||
<Icon icon="mdi:filter" className="text-xl" />
|
<Icon icon="mdi:filter" className="text-xl" />
|
||||||
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
|
Filter
|
||||||
|
<span className="w-5">({filterCount})</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
|
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 ${
|
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"}`}
|
||||||
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Arrow */}
|
{/* 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="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 />
|
<TagFilter />
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||||
<hr className="flex-grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Platform</span>
|
<span>Tags Exclude</span>
|
||||||
<hr className="flex-grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
<PlatformSelect />
|
<TagFilter isExclude />
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
<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>
|
<span>Gender</span>
|
||||||
<hr className="flex-grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
<GenderSelect />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default function GenderSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
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 { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
import FilterMenu from "./filter-menu";
|
|
||||||
import SortSelect from "./sort-select";
|
import SortSelect from "./sort-select";
|
||||||
import Carousel from "../carousel";
|
import Carousel from "../carousel";
|
||||||
import LikeButton from "../like-button";
|
import LikeButton from "../like-button";
|
||||||
import DeleteMiiButton from "../delete-mii";
|
import DeleteMiiButton from "../delete-mii";
|
||||||
import Pagination from "./pagination";
|
import Pagination from "./pagination";
|
||||||
|
import FilterMenu from "./filter-menu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: { [key: string]: string | string[] | undefined };
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
|
|
@ -23,26 +23,13 @@ interface Props {
|
||||||
inLikesPage?: boolean; // Self-explanatory
|
inLikesPage?: boolean; // Self-explanatory
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MiiList({
|
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
|
||||||
searchParams,
|
|
||||||
userId,
|
|
||||||
inLikesPage,
|
|
||||||
}: Props) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
const parsed = searchSchema.safeParse(searchParams);
|
const parsed = searchSchema.safeParse(searchParams);
|
||||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||||
|
|
||||||
const {
|
const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
|
||||||
q: query,
|
|
||||||
sort,
|
|
||||||
tags,
|
|
||||||
platform,
|
|
||||||
gender,
|
|
||||||
page = 1,
|
|
||||||
limit = 24,
|
|
||||||
seed,
|
|
||||||
} = parsed.data;
|
|
||||||
|
|
||||||
// My Likes page
|
// My Likes page
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
@ -60,18 +47,17 @@ export default async function MiiList({
|
||||||
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||||
// Searching
|
// Searching
|
||||||
...(query && {
|
...(query && {
|
||||||
OR: [
|
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
|
||||||
{ tags: { has: query } },
|
|
||||||
{ description: { contains: query, mode: "insensitive" } },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
// Tag filtering
|
// Tag filtering
|
||||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||||
|
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||||
// Platform
|
// Platform
|
||||||
...(platform && { platform: { equals: platform } }),
|
...(platform && { platform: { equals: platform } }),
|
||||||
// Gender
|
// Gender
|
||||||
...(gender && { gender: { equals: gender } }),
|
...(gender && { gender: { equals: gender } }),
|
||||||
|
// Allow Copying
|
||||||
|
...(allowCopying && { allowedCopying: true }),
|
||||||
// Profiles
|
// Profiles
|
||||||
...(userId && { userId }),
|
...(userId && { userId }),
|
||||||
};
|
};
|
||||||
|
|
@ -93,6 +79,7 @@ export default async function MiiList({
|
||||||
tags: true,
|
tags: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
gender: true,
|
gender: true,
|
||||||
|
allowedCopying: true,
|
||||||
// Mii liked check
|
// Mii liked check
|
||||||
...(session?.user?.id && {
|
...(session?.user?.id && {
|
||||||
likedBy: {
|
likedBy: {
|
||||||
|
|
@ -113,9 +100,6 @@ export default async function MiiList({
|
||||||
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||||
|
|
||||||
if (sort === "random") {
|
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
|
// Get all IDs that match the where conditions
|
||||||
const matchingIds = await prisma.mii.findMany({
|
const matchingIds = await prisma.mii.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
@ -123,10 +107,12 @@ export default async function MiiList({
|
||||||
});
|
});
|
||||||
|
|
||||||
totalCount = matchingIds.length;
|
totalCount = matchingIds.length;
|
||||||
filteredCount = Math.min(matchingIds.length, limit);
|
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||||
|
|
||||||
if (matchingIds.length === 0) return;
|
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());
|
const rng = seedrandom(randomSeed.toString());
|
||||||
|
|
||||||
// Randomize all IDs using the Durstenfeld algorithm
|
// Randomize all IDs using the Durstenfeld algorithm
|
||||||
|
|
@ -136,7 +122,7 @@ export default async function MiiList({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to number[] array
|
// 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({
|
list = await prisma.mii.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -183,22 +169,14 @@ export default async function MiiList({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{totalCount == filteredCount ? (
|
{totalCount == filteredCount ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-2xl font-bold text-amber-900">
|
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
||||||
{totalCount}
|
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
||||||
</span>
|
|
||||||
<span className="text-lg text-amber-700">
|
|
||||||
{totalCount === 1 ? "Mii" : "Miis"}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-2xl font-bold text-amber-900">
|
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
|
||||||
{filteredCount}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-amber-700">of</span>
|
<span className="text-sm text-amber-700">of</span>
|
||||||
<span className="text-lg font-semibold text-amber-800">
|
<span className="text-lg font-semibold text-amber-800">{totalCount}</span>
|
||||||
{totalCount}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg text-amber-700">Miis</span>
|
<span className="text-lg text-amber-700">Miis</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -220,66 +198,37 @@ export default async function MiiList({
|
||||||
images={[
|
images={[
|
||||||
`/mii/${mii.id}/image?type=mii`,
|
`/mii/${mii.id}/image?type=mii`,
|
||||||
`/mii/${mii.id}/image?type=qr-code`,
|
`/mii/${mii.id}/image?type=qr-code`,
|
||||||
...Array.from(
|
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||||
{ length: mii.imageCount },
|
|
||||||
(_, index) => `/mii/${mii.id}/image?type=image${index}`
|
|
||||||
),
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-1 h-full">
|
<div className="p-4 flex flex-col gap-1 h-full">
|
||||||
<Link
|
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}>
|
||||||
href={`/mii/${mii.id}`}
|
|
||||||
className="font-bold text-2xl line-clamp-1"
|
|
||||||
title={mii.name}
|
|
||||||
>
|
|
||||||
{mii.name}
|
{mii.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div id="tags" className="flex flex-wrap gap-1">
|
<div id="tags" className="flex flex-wrap gap-1">
|
||||||
{mii.tags.map((tag) => (
|
{mii.tags.map((tag) => (
|
||||||
<Link
|
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||||
href={{ query: { tags: tag } }}
|
|
||||||
key={tag}
|
|
||||||
className="px-2 py-1 bg-orange-300 rounded-full text-xs"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto grid grid-cols-2 items-center">
|
<div className="mt-auto grid grid-cols-2 items-center">
|
||||||
<LikeButton
|
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate />
|
||||||
likes={mii.likes}
|
|
||||||
miiId={mii.id}
|
|
||||||
isLiked={mii.isLiked}
|
|
||||||
isLoggedIn={session?.user != null}
|
|
||||||
abbreviate
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!userId && (
|
{!userId && (
|
||||||
<Link
|
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
|
||||||
href={`/profile/${mii.user?.id}`}
|
|
||||||
className="text-sm text-right overflow-hidden text-ellipsis"
|
|
||||||
>
|
|
||||||
@{mii.user?.username}
|
@{mii.user?.username}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userId && Number(session?.user.id) == userId && (
|
{userId && Number(session?.user.id) == userId && (
|
||||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||||
<Link
|
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
|
||||||
href={`/edit/${mii.id}`}
|
|
||||||
title="Edit Mii"
|
|
||||||
aria-label="Edit Mii"
|
|
||||||
data-tooltip="Edit"
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:pencil" />
|
<Icon icon="mdi:pencil" />
|
||||||
</Link>
|
</Link>
|
||||||
<DeleteMiiButton
|
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
|
||||||
miiId={mii.id}
|
|
||||||
miiName={mii.name}
|
|
||||||
likes={mii.likes}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -54,11 +54,7 @@ export default function SortSelect() {
|
||||||
>
|
>
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
items.map((item, index) => (
|
items.map((item, index) => (
|
||||||
<li
|
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||||
key={item}
|
|
||||||
{...getItemProps({ item, index })}
|
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
|
||||||
>
|
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import TagSelector from "../tag-selector";
|
import TagSelector from "../tag-selector";
|
||||||
|
|
||||||
export default function TagFilter() {
|
interface Props {
|
||||||
|
isExclude?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagFilter({ isExclude }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const rawTags = searchParams.get("tags") || "";
|
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||||
const preexistingTags = useMemo(
|
const preexistingTags = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rawTags
|
rawTags
|
||||||
|
|
@ -18,7 +22,7 @@ export default function TagFilter() {
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter((tag) => tag.length > 0)
|
.filter((tag) => tag.length > 0)
|
||||||
: [],
|
: [],
|
||||||
[rawTags]
|
[rawTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||||
|
|
@ -39,20 +43,20 @@ export default function TagFilter() {
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
params.set("tags", stateTags);
|
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||||
} else {
|
} else {
|
||||||
params.delete("tags");
|
params.delete(isExclude ? "exclude" : "tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72">
|
<div className="w-72">
|
||||||
<TagSelector tags={tags} setTags={setTags} />
|
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { redirect, useSearchParams } from "next/navigation";
|
import { redirect, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import { querySchema } from "@/lib/schemas";
|
import { querySchema } from "@/lib/schemas";
|
||||||
|
|
||||||
export default function SearchBar() {
|
export default function SearchBar() {
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const result = querySchema.safeParse(query);
|
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
|
// Clone current search params and add query param
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set("q", query);
|
params.set("q", query);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
redirect(`/?${params.toString()}`);
|
router.push(`/?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default function SubmitForm() {
|
||||||
if (files.length >= 3) return;
|
if (files.length >= 3) return;
|
||||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||||
},
|
},
|
||||||
[files.length]
|
[files.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||||
|
|
@ -101,7 +101,7 @@ export default function SubmitForm() {
|
||||||
const { id, error } = await response.json();
|
const { id, error } = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(error);
|
setError(String(error)); // app can crash if error message is not a string
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Webcam from "react-webcam";
|
|
||||||
import jsQR from "jsqr";
|
import jsQR from "jsqr";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
|
@ -17,14 +16,12 @@ interface Props {
|
||||||
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(
|
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const webcamRef = useRef<Webcam>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const requestRef = useRef<number>(null);
|
const requestRef = useRef<number>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
|
@ -42,8 +39,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
selectedItem,
|
selectedItem,
|
||||||
} = useSelect({
|
} = useSelect({
|
||||||
items: cameraItems,
|
items: cameraItems,
|
||||||
selectedItem:
|
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||||
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
|
||||||
onSelectedItemChange: ({ selectedItem }) => {
|
onSelectedItemChange: ({ selectedItem }) => {
|
||||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||||
},
|
},
|
||||||
|
|
@ -55,12 +51,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
// Continue scanning in a loop
|
// Continue scanning in a loop
|
||||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||||
|
|
||||||
const webcam = webcamRef.current;
|
const video = videoRef.current;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!webcam || !canvas) return;
|
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
||||||
|
|
||||||
const video = webcam.video;
|
|
||||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
@ -69,14 +62,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||||
|
|
||||||
const imageData = ctx.getImageData(
|
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||||
0,
|
|
||||||
0,
|
|
||||||
video.videoWidth,
|
|
||||||
video.videoHeight
|
|
||||||
);
|
|
||||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||||
if (!code) return;
|
if (!code || !code.binaryData) return;
|
||||||
|
|
||||||
// Cancel animation frame to stop scanning
|
// Cancel animation frame to stop scanning
|
||||||
if (requestRef.current) {
|
if (requestRef.current) {
|
||||||
|
|
@ -84,15 +72,20 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
requestRef.current = null;
|
requestRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQrBytesRaw(code.binaryData!);
|
setQrBytesRaw(code.binaryData);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||||
|
|
||||||
const requestPermission = async () => {
|
const requestPermission = () => {
|
||||||
|
if (!navigator.mediaDevices) return;
|
||||||
|
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ video: true })
|
.getUserMedia({ video: true, audio: false })
|
||||||
.then(() => setPermissionGranted(true))
|
.then(() => setPermissionGranted(true))
|
||||||
.catch(() => setPermissionGranted(false));
|
.catch((err) => {
|
||||||
|
setPermissionGranted(false);
|
||||||
|
console.error("An error occurred trying to access the camera", err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|
@ -106,34 +99,50 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// slight delay to trigger animation
|
// slight delay to trigger animation
|
||||||
setTimeout(() => setIsVisible(true), 10);
|
setTimeout(() => setIsVisible(true), 10);
|
||||||
|
requestPermission();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen || !permissionGranted) return;
|
||||||
requestPermission();
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices.enumerateDevices) return;
|
navigator.mediaDevices
|
||||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
.enumerateDevices()
|
||||||
|
.then((devices) => {
|
||||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||||
setDevices(videoDevices);
|
setDevices(videoDevices);
|
||||||
if (!selectedDeviceId && videoDevices.length > 0) {
|
|
||||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isOpen, selectedDeviceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
||||||
if (!isOpen || !permissionGranted) return;
|
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);
|
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
return () => {
|
return () => {
|
||||||
if (requestRef.current) {
|
if (requestRef.current) {
|
||||||
cancelAnimationFrame(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;
|
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 className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||||
<div
|
<div
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||||
isVisible ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -153,12 +160,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||||
<button
|
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||||
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" />
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,9 +193,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
<li
|
<li
|
||||||
key={item.value}
|
key={item.value}
|
||||||
{...getItemProps({ item, index })}
|
{...getItemProps({ item, index })}
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||||
highlightedIndex === index ? "bg-black/15" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -204,51 +204,19 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative w-full aspect-square">
|
<div className="relative w-full aspect-square">
|
||||||
{!permissionGranted ? (
|
{!permissionGranted && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
|
<div className="absolute inset-0 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">
|
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||||
Camera access denied
|
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
|
||||||
</p>
|
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
|
||||||
<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
|
Request Permission
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<Webcam
|
<video ref={videoRef} className="size-full object-cover rounded-2xl border-2 border-amber-500" />
|
||||||
key={selectedDeviceId}
|
|
||||||
ref={webcamRef}
|
|
||||||
audio={false}
|
|
||||||
videoConstraints={{
|
|
||||||
deviceId: selectedDeviceId
|
|
||||||
? { exact: selectedDeviceId }
|
|
||||||
: undefined,
|
|
||||||
...(selectedDeviceId
|
|
||||||
? {}
|
|
||||||
: { facingMode: { ideal: "environment" } }),
|
|
||||||
}}
|
|
||||||
onUserMedia={async () => {
|
|
||||||
const newDevices =
|
|
||||||
await navigator.mediaDevices.enumerateDevices();
|
|
||||||
const videoDevices = newDevices.filter(
|
|
||||||
(d) => d.kind === "videoinput"
|
|
||||||
);
|
|
||||||
setDevices(videoDevices);
|
|
||||||
}}
|
|
||||||
className="size-full object-cover rounded-2xl border-2 border-amber-500"
|
|
||||||
/>
|
|
||||||
<QrFinder />
|
<QrFinder />
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,18 @@ interface Props {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
showTagLimit?: boolean;
|
showTagLimit?: boolean;
|
||||||
|
isExclude?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagRegex = /^[a-z0-9-_]*$/;
|
const tagRegex = /^[a-z0-9-_]*$/;
|
||||||
const predefinedTags = [
|
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||||
"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 [inputValue, setInputValue] = useState<string>("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const getFilteredItems = (): string[] =>
|
const getFilteredItems = (): string[] =>
|
||||||
predefinedTags
|
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||||
.filter((item) =>
|
|
||||||
item.toLowerCase().includes(inputValue?.toLowerCase() || "")
|
|
||||||
)
|
|
||||||
.filter((item) => !tags.includes(item));
|
|
||||||
|
|
||||||
const filteredItems = getFilteredItems();
|
const filteredItems = getFilteredItems();
|
||||||
const isMaxItemsSelected = tags.length >= 8;
|
const isMaxItemsSelected = tags.length >= 8;
|
||||||
|
|
@ -49,37 +35,39 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
setTags(tags.filter((t) => t !== tag));
|
setTags(tags.filter((t) => t !== tag));
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||||
isOpen,
|
|
||||||
openMenu,
|
|
||||||
getToggleButtonProps,
|
|
||||||
getMenuProps,
|
|
||||||
getInputProps,
|
|
||||||
getItemProps,
|
|
||||||
highlightedIndex,
|
|
||||||
} = useCombobox<string>({
|
|
||||||
inputValue,
|
inputValue,
|
||||||
items: filteredItems,
|
items: filteredItems,
|
||||||
|
selectedItem: null,
|
||||||
onInputValueChange: ({ inputValue }) => {
|
onInputValueChange: ({ inputValue }) => {
|
||||||
if (inputValue && !tagRegex.test(inputValue)) return;
|
const newValue = inputValue || "";
|
||||||
setInputValue(inputValue || "");
|
if (newValue && !tagRegex.test(newValue)) return;
|
||||||
|
setInputValue(newValue);
|
||||||
},
|
},
|
||||||
onStateChange: ({ type, selectedItem }) => {
|
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||||
addTag(selectedItem);
|
addTag(selectedItem);
|
||||||
setInputValue("");
|
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>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||||
addTag(inputValue);
|
addTag(inputValue);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
}
|
} else if (event.key === "Backspace" && inputValue === "") {
|
||||||
|
|
||||||
// Spill onto last tag
|
// Spill onto last tag
|
||||||
if (event.key === "Backspace" && inputValue === "") {
|
|
||||||
const lastTag = tags[tags.length - 1];
|
const lastTag = tags[tags.length - 1];
|
||||||
setInputValue(lastTag);
|
setInputValue(lastTag);
|
||||||
removeTag(lastTag);
|
removeTag(lastTag);
|
||||||
|
|
@ -104,10 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex flex-wrap gap-1.5 w-full">
|
<div className="flex flex-wrap gap-1.5 w-full">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span
|
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||||
key={tag}
|
|
||||||
className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -137,17 +122,9 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
<div
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
className="flex items-center gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{hasSelectedItems && (
|
{hasSelectedItems && (
|
||||||
<button
|
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||||
type="button"
|
|
||||||
aria-label="Remove All Tags"
|
|
||||||
className="text-black cursor-pointer"
|
|
||||||
onClick={() => setTags([])}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:close" />
|
<Icon icon="mdi:close" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -176,9 +153,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item}
|
||||||
{...getItemProps({ item, index })}
|
{...getItemProps({ item, index })}
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||||
highlightedIndex === index ? "bg-black/15" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -202,9 +177,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
{showTagLimit && (
|
{showTagLimit && (
|
||||||
<div className="mt-1.5 text-xs min-h-4">
|
<div className="mt-1.5 text-xs min-h-4">
|
||||||
{isMaxItemsSelected ? (
|
{isMaxItemsSelected ? (
|
||||||
<span className="text-red-400 font-medium">
|
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
|
||||||
Maximum of 8 tags reached. Remove a tag to add more.
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-black/60">{tags.length}/8 tags</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";
|
import { BitStream } from "bit-buffer";
|
||||||
|
|
||||||
|
|
@ -11,78 +12,34 @@ export default class ExtendedBitStream extends BitStream {
|
||||||
this.bigEndian = !this.bigEndian;
|
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 {
|
public alignByte(): void {
|
||||||
// @ts-expect-error _index is private
|
const nextClosestByteIndex = 8 * Math.ceil(this.index / 8);
|
||||||
const nextClosestByteIndex = 8 * Math.ceil(this._index / 8);
|
const bitDistance = nextClosestByteIndex - this.index;
|
||||||
// @ts-expect-error _index is private
|
|
||||||
const bitDistance = nextClosestByteIndex - this._index;
|
|
||||||
|
|
||||||
this.skipBits(bitDistance);
|
this.skipBits(bitDistance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bitSeek(bitPos: number): void {
|
|
||||||
// @ts-expect-error _index is private
|
|
||||||
this._index = bitPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
public skipBits(bitCount: number): void {
|
public skipBits(bitCount: number): void {
|
||||||
// @ts-expect-error _index is private
|
this.index += bitCount;
|
||||||
this._index += bitCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public skipBytes(bytes: number): void {
|
|
||||||
const bits = bytes * 8;
|
|
||||||
this.skipBits(bits);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public skipBit(): void {
|
public skipBit(): void {
|
||||||
this.skipBits(1);
|
this.skipBits(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public skipInt8(): void {
|
|
||||||
this.skipBytes(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public skipInt16(): void {
|
public skipInt16(): void {
|
||||||
// Skipping a uint16 is the same as skipping 2 uint8's
|
// Skipping a uint16 is the same as skipping 2 uint8's
|
||||||
this.skipBytes(2);
|
this.skipBits(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readBit(): number {
|
public readBit(): number {
|
||||||
return this.readBits(1);
|
return this.readBits(1, false);
|
||||||
}
|
|
||||||
|
|
||||||
public readBytes(length: number): number[] {
|
|
||||||
return Array(length)
|
|
||||||
.fill(0)
|
|
||||||
.map(() => this.readUint8());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public readBuffer(length: number): Buffer {
|
public readBuffer(length: number): Buffer {
|
||||||
return Buffer.from(this.readBytes(length));
|
return Buffer.from(super.readBytes(length));
|
||||||
}
|
}
|
||||||
|
|
||||||
public readUTF16String(length: number): string {
|
public readUTF16String(length: number): string {
|
||||||
return this.readBuffer(length).toString("utf16le").replace(/\0.*$/, "");
|
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",
|
"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"];
|
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 {
|
export default class Mii {
|
||||||
public bitStream: ExtendedBitStream;
|
public bitStream: ExtendedBitStream;
|
||||||
|
public buffer: Buffer;
|
||||||
|
|
||||||
// Mii data
|
// Mii data
|
||||||
// can be sure that these are all initialized in decode()
|
// can be sure that these are all initialized in decode()
|
||||||
|
|
@ -150,92 +158,292 @@ export default class Mii {
|
||||||
public checksum!: number;
|
public checksum!: number;
|
||||||
|
|
||||||
constructor(buffer: Buffer) {
|
constructor(buffer: Buffer) {
|
||||||
|
this.buffer = buffer;
|
||||||
this.bitStream = new ExtendedBitStream(buffer);
|
this.bitStream = new ExtendedBitStream(buffer);
|
||||||
this.decode();
|
this.decode();
|
||||||
}
|
}
|
||||||
|
|
||||||
public validate(): void {
|
public validate(): void {
|
||||||
// Size check
|
// 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
|
// Value range and type checks
|
||||||
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
|
assert.ok(
|
||||||
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
|
this.version === 0 || this.version === 3,
|
||||||
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
|
`Invalid Mii version. Got ${this.version}, expected 0 or 3`,
|
||||||
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.equal(
|
||||||
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
|
typeof this.allowCopying,
|
||||||
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
|
"boolean",
|
||||||
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
|
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`,
|
||||||
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(
|
||||||
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
|
typeof this.profanityFlag,
|
||||||
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
|
"boolean",
|
||||||
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
|
`Invalid Mii profanity flag. Got ${this.profanityFlag}, 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(
|
||||||
|
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(
|
assert.equal(
|
||||||
this.consoleMAC.length,
|
this.consoleMAC.length,
|
||||||
6,
|
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.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.equal(
|
||||||
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
|
typeof this.flipHair,
|
||||||
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
|
"boolean",
|
||||||
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
|
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`,
|
||||||
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(
|
||||||
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
|
Util.inRange(this.eyeType, Util.range(60)),
|
||||||
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
|
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`,
|
||||||
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
|
);
|
||||||
|
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.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(
|
assert.ok(
|
||||||
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
|
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(
|
assert.ok(
|
||||||
Util.inRange(this.mouthHorizontalStretch, Util.range(7)),
|
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.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(
|
||||||
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
|
Util.inRange(this.mustacheScale, Util.range(9)),
|
||||||
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
|
`Invalid Mii mustache scale. Got ${this.mustacheScale}, 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(
|
||||||
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
|
Util.inRange(this.mustacheYPosition, Util.range(17)),
|
||||||
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
|
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`,
|
||||||
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(
|
||||||
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
|
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
|
// 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");
|
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 {
|
public calculateCRC(): number {
|
||||||
const view = this.bitStream.view;
|
// #view is inaccessible
|
||||||
|
const data = new Uint8Array(
|
||||||
// @ts-expect-error _view is private
|
this.buffer.buffer,
|
||||||
const data = view._view.subarray(0, 0x5e);
|
this.buffer.byteOffset,
|
||||||
|
this.buffer.length,
|
||||||
|
).subarray(0, 0x5e);
|
||||||
|
|
||||||
let crc = 0x0000;
|
let crc = 0x0000;
|
||||||
|
|
||||||
|
|
@ -506,7 +719,7 @@ export default class Mii {
|
||||||
instanceCount?: number;
|
instanceCount?: number;
|
||||||
instanceRotationMode?: string;
|
instanceRotationMode?: string;
|
||||||
data?: string;
|
data?: string;
|
||||||
} = STUDIO_RENDER_DEFAULTS
|
} = STUDIO_RENDER_DEFAULTS,
|
||||||
): string {
|
): string {
|
||||||
const params = {
|
const params = {
|
||||||
...STUDIO_RENDER_DEFAULTS,
|
...STUDIO_RENDER_DEFAULTS,
|
||||||
|
|
@ -514,11 +727,23 @@ export default class Mii {
|
||||||
data: this.encodeStudio().toString("hex"),
|
data: this.encodeStudio().toString("hex"),
|
||||||
};
|
};
|
||||||
|
|
||||||
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
|
params.type = STUDIO_RENDER_TYPES.includes(params.type as string)
|
||||||
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
|
? 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.width = Util.clamp(params.width, 512);
|
||||||
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
|
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string)
|
||||||
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
|
? 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.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
|
||||||
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
||||||
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
||||||
|
|
@ -528,16 +753,25 @@ export default class Mii {
|
||||||
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
||||||
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
||||||
params.lightZDirection = Util.clamp(params.lightZDirection, 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
|
? params.lightDirectionMode
|
||||||
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
||||||
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
|
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
|
? params.instanceRotationMode
|
||||||
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
||||||
|
|
||||||
// converts non-string params to strings
|
// 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") {
|
if (params.lightDirectionMode === "none") {
|
||||||
query.delete("lightDirectionMode");
|
query.delete("lightDirectionMode");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { Redis } from "ioredis";
|
import { createClient, RedisClientType } from "redis";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
|
||||||
const redis = new Redis(process.env.REDIS_URL!);
|
const WINDOW_SIZE = 60;
|
||||||
const windowSize = 60;
|
let client: RedisClientType | null = null;
|
||||||
|
|
||||||
interface RateLimitData {
|
interface RateLimitData {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -12,6 +12,17 @@ interface RateLimitData {
|
||||||
expires: number;
|
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
|
// Fixed window implementation
|
||||||
export class RateLimit {
|
export class RateLimit {
|
||||||
private request: NextRequest;
|
private request: NextRequest;
|
||||||
|
|
@ -37,22 +48,19 @@ export class RateLimit {
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const seconds = Math.floor(now / 1000);
|
const seconds = Math.floor(now / 1000);
|
||||||
const currentWindow = Math.floor(seconds / windowSize) * windowSize;
|
const currentWindow = Math.floor(seconds / WINDOW_SIZE) * WINDOW_SIZE;
|
||||||
const expireAt = currentWindow + windowSize;
|
const expireAt = currentWindow + WINDOW_SIZE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a Redis transaction
|
const client = await getRedisClient();
|
||||||
const tx = redis.multi();
|
|
||||||
tx.incr(key);
|
|
||||||
tx.expireat(key, expireAt);
|
|
||||||
|
|
||||||
// Execute transaction and get the count
|
// Execute a Redis transaction and get the count
|
||||||
const results = await tx.exec();
|
const [result] = await client.multi().incr(key).expireAt(key, expireAt).exec();
|
||||||
if (!results) {
|
if (!result) {
|
||||||
throw new Error("Redis transaction failed");
|
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 success = count <= this.maxRequests;
|
||||||
const remaining = Math.max(0, this.maxRequests - count);
|
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" })
|
.max(20, { error: "Tags cannot be more than 20 characters long" })
|
||||||
.regex(/^[a-z0-9-_]+$/, {
|
.regex(/^[a-z0-9-_]+$/, {
|
||||||
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.min(1, { error: "There must be at least 1 tag" })
|
.min(1, { error: "There must be at least 1 tag" })
|
||||||
.max(8, { error: "There cannot be more than 8 tags" });
|
.max(8, { error: "There cannot be more than 8 tags" });
|
||||||
|
|
@ -47,10 +47,20 @@ export const searchSchema = z.object({
|
||||||
value
|
value
|
||||||
?.split(",")
|
?.split(",")
|
||||||
.map((tag) => tag.trim())
|
.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(),
|
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(),
|
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
|
// todo: incorporate tagsSchema
|
||||||
// Pages
|
// Pages
|
||||||
limit: z.coerce
|
limit: z.coerce
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue