Merge branch 'feat/living-the-dream-qr-code' into feat/living-the-dream-access-key

This commit is contained in:
trafficlunar 2026-02-20 15:30:06 +00:00
commit 7e182725ac
30 changed files with 3177 additions and 3109 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
# next.js # next.js
/.next/ /.next/
/out/ /out/
certificates/
# production # production
/build /build

130
API.md
View file

@ -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 Miis ID. |
| **type** | string | **Yes** | One of: `mii`, `qr-code`, `metadata`. |
#### **Examples**
```
https://tomodachishare.com/mii/1/image?type=mii
```
```
https://tomodachishare.com/mii/2/image?type=qr-code
```
```
https://tomodachishare.com/mii/3/image?type=metadata
```
#### **Response**
Returns the image file.
---
### **Get Mii Data**
`GET /mii/{id}/data`
Fetches metadata for a specific Mii.
#### **Path Parameters**
| Name | Type | Required | Description |
| ------ | ------ | -------- | ------------- |
| **id** | number | **Yes** | The Miis ID. |
#### **Example**
```
https://tomodachishare.com/mii/1/data
```
#### **Response**
```json
{
"id": 1,
"name": "Frieren",
"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_

View file

@ -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">

View file

@ -1,66 +1,59 @@
{ {
"name": "tomodachi-share", "name": "tomodachi-share",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.14.0", "packageManager": "pnpm@10.28.2",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.1.1", "@2toad/profanity": "^3.2.0",
"@auth/prisma-adapter": "2.10.0", "@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.19.2",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.18", "dayjs": "^1.11.19",
"downshift": "^9.0.10", "downshift": "^9.0.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^21.0.0", "file-type": "^21.3.0",
"ioredis": "^5.7.0", "jsqr": "^1.4.0",
"jsqr": "^1.4.0", "next": "16.1.6",
"next": "16.0.0-beta.0", "next-auth": "5.0.0-beta.30",
"next-auth": "5.0.0-beta.25", "qrcode-generator": "^2.0.4",
"qrcode-generator": "^2.0.4", "react": "^19.2.4",
"react": "19.2.0", "react-dom": "^19.2.4",
"react-dom": "19.2.0", "react-dropzone": "^14.3.8",
"react-dropzone": "^14.3.8", "redis": "^5.10.0",
"react-webcam": "^7.2.0", "satori": "^0.19.1",
"satori": "^0.18.2", "seedrandom": "^3.0.5",
"seedrandom": "^3.0.5", "sharp": "^0.34.5",
"sharp": "^0.34.3", "sjcl-with-all": "1.0.8",
"sjcl-with-all": "1.0.8", "swr": "^2.3.8",
"swr": "^2.3.6", "zod": "^4.3.6"
"zod": "^4.1.8" },
}, "devDependencies": {
"devDependencies": { "@eslint/eslintrc": "^3.3.3",
"@eslint/eslintrc": "^3.3.1", "@iconify/react": "^6.0.2",
"@iconify/react": "^6.0.1", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/postcss": "^4.1.13", "@types/canvas-confetti": "^1.9.0",
"@types/canvas-confetti": "^1.9.0", "@types/node": "^25.1.0",
"@types/node": "^24.3.1", "@types/react": "^19.2.10",
"@types/react": "19.2.2", "@types/react-dom": "^19.2.3",
"@types/react-dom": "19.2.1", "@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.35.0", "eslint-config-next": "16.1.6",
"eslint-config-next": "16.0.0-beta.0", "prisma": "^6.19.2",
"prisma": "^6.16.1", "schema-dts": "^1.1.5",
"schema-dts": "^1.1.5", "tailwindcss": "^4.1.18",
"tailwindcss": "^4.1.13", "typescript": "^5.9.3",
"typescript": "^5.9.2", "vitest": "^4.0.18"
"vitest": "^3.2.4" }
},
"pnpm": {
"overrides": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1"
}
}
} }

File diff suppressed because it is too large Load diff

View file

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

View file

@ -56,7 +56,7 @@ const submitSchema = z
{ {
message: "Access key, gender, and Mii portrait image is required for Switch", message: "Access key, gender, and Mii portrait image is required for Switch",
path: ["accessKey", "gender", "miiPortraitImage"], path: ["accessKey", "gender", "miiPortraitImage"],
} },
); );
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -102,17 +102,29 @@ export async function POST(request: NextRequest) {
}); });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const data = parsed.data; const {
platform,
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
accessKey,
gender,
miiPortraitImage,
image1,
image2,
image3,
} = parsed.data;
// Censor potential inappropriate words // 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);
@ -124,16 +136,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) {
@ -145,17 +157,17 @@ 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",
// Access key only for Switch // Access key only for Switch
accessKey: data.platform === "SWITCH" ? data.accessKey : null, accessKey: platform === "SWITCH" ? accessKey : null,
// 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,
@ -174,7 +186,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!);
@ -183,8 +195,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");
@ -200,7 +212,7 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
} }
if (data.platform === "THREE_DS") { if (platform === "THREE_DS") {
try { try {
// Generate a new QR code for aesthetic reasons // Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes); const byteString = String.fromCharCode(...qrBytes);
@ -235,7 +247,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,
); );
} }
@ -248,7 +260,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

View file

@ -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 */
* { * {

View file

@ -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>
); );

View file

@ -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,
});
}

View file

@ -103,7 +103,7 @@ export default async function MiiPage({ params }: Props) {
userId: Number(session.user.id), userId: Number(session.user.id),
}, },
select: { userId: true }, select: { userId: true },
} }
: false, : false,
_count: { _count: {
select: { likedBy: true }, // Get total like count select: { likedBy: true }, // Get total like count
@ -239,6 +239,7 @@ export default async function MiiPage({ params }: Props) {
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1> <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">

View file

@ -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>

View file

@ -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
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); let value: boolean | null = true;
const { value } = await response.json(); try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
value = await response.json();
} catch (error) {
return <p>An error occurred!</p>;
}
if (!value) if (!value)
return ( return (

View 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>
);
}

View file

@ -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"> <Icon icon="material-symbols:close-rounded" />
<button type="button" aria-label="Close" onClick={close} className="text-2xl cursor-pointer"> </button>
<Icon icon="material-symbols:close-rounded" />
</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"}`}
{imagesMap.map((image, index) => ( ref={emblaRef}
<div key={index} className="shrink-0 w-full"> >
<Image <div className="flex h-full">
src={image} {imagesMap.map((image, index) => (
alt={alt} <div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
width={576} <Image
height={576} src={image}
className="object-contain" alt={alt}
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }} width={896}
/> height={896}
</div> priority={index === selectedIndex}
))} loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
</div> className="max-w-full max-h-full object-contain drop-shadow-lg"
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
/>
</div>
))}
</div> </div>
</div> </div>
{images.length != 0 && ( {images.length > 1 && (
<> <>
{/* Carousel counter */}
<div
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{selectedIndex + 1} / {images.length}
</div>
{/* Carousel buttons */} {/* Carousel buttons */}
{/* Prev button */} {/* Prev button */}
<div <button
className={`z-50 absolute left-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${ type="button"
isVisible ? "opacity-100" : "opacity-0" aria-label="Scroll Carousel Left"
}`} onClick={() => emblaApi?.scrollPrev()}
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"}`}
> >
<button <Icon icon="ic:round-chevron-left" />
type="button" </button>
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
disabled={!emblaApi?.canScrollPrev()}
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
</div>
{/* Next button */} {/* Next button */}
<div <button
className={`z-50 absolute right-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${ type="button"
isVisible ? "opacity-100" : "opacity-0" aria-label="Scroll Carousel Right"
}`} onClick={() => emblaApi?.scrollNext()}
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"}`}
> >
<button <Icon icon="ic:round-chevron-right" />
type="button" </button>
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
disabled={!emblaApi?.canScrollNext()}
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-right" />
</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,
)} )}
</> </>
); );

View file

@ -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);

View file

@ -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>

View file

@ -28,7 +28,7 @@ export default function GenderSelect() {
} }
startTransition(() => { startTransition(() => {
router.push(`?${params.toString()}`); router.push(`?${params.toString()}`, { scroll: false });
}); });
}; };

View file

@ -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 };
@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data; const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -51,10 +51,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}), }),
// Tag filtering // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(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 }),
}; };
@ -76,6 +79,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
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: {
@ -96,9 +100,6 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
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,
@ -106,10 +107,12 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}); });
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
@ -119,7 +122,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
} }
// 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: {
@ -194,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel <Carousel
images={[ images={[
`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []), `/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`), ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]} ]}
/> />

View file

@ -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>
);
}

View file

@ -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>
))} ))}

View file

@ -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>
); );
} }

View file

@ -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) => {

View file

@ -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);
@ -104,7 +104,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;
} }

View file

@ -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(() => {
if (!isOpen) return;
requestPermission();
if (!navigator.mediaDevices.enumerateDevices) return;
navigator.mediaDevices.enumerateDevices().then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
if (!selectedDeviceId && videoDevices.length > 0) {
setSelectedDeviceId(videoDevices[0].deviceId);
}
});
}, [isOpen, selectedDeviceId]);
useEffect(() => { useEffect(() => {
if (!isOpen || !permissionGranted) return; if (!isOpen || !permissionGranted) return;
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
if (!targetDeviceId) return;
setSelectedDeviceId(targetDeviceId);
// start camera stream
return navigator.mediaDevices.getUserMedia({
video: { deviceId: targetDeviceId },
audio: false,
});
})
.then((stream) => {
if (!stream || !videoRef.current) return;
videoRef.current.srcObject = stream;
videoRef.current.play();
})
.catch((err) => console.error("Camera error", err));
requestRef.current = requestAnimationFrame(scanQRCode); 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
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId
? { exact: selectedDeviceId }
: undefined,
...(selectedDeviceId
? {}
: { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices =
await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter(
(d) => d.kind === "videoinput"
);
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)} )}
<video ref={videoRef} className="size-full object-cover rounded-2xl border-2 border-amber-500" />
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</div> </div>
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">

View file

@ -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>
)} )}

View file

@ -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);
}
} }

View file

@ -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 =
? params.instanceRotationMode STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
: STUDIO_RENDER_DEFAULTS.instanceRotationMode; params.instanceRotationMode,
)
? params.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");

View file

@ -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);

View file

@ -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