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/
/out/
certificates/
# production
/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
**TomodachiShare** is a fan-made website that lets you easily discover, upload, and share **Mii characters** for the game **Tomodachi Life**.
TomodachiShare is a fan-made website that lets you easily discover, upload, and share Mii characters for the game Tomodachi Life.
- 📷 Upload or scan your Mii QR codes
- ✨ Generates Mii renders for previews
@ -27,8 +27,6 @@
### <a href="/DEVELOPMENT.md">Development Instructions</a>
### <a href="/API.md">API Reference</a>
---
<p align="center">

View file

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

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",
path: ["accessKey", "gender", "miiPortraitImage"],
}
},
);
export async function POST(request: NextRequest) {
@ -102,17 +102,29 @@ export async function POST(request: NextRequest) {
});
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const data = parsed.data;
const {
platform,
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
accessKey,
gender,
miiPortraitImage,
image1,
image2,
image3,
} = parsed.data;
// Censor potential inappropriate words
const name = profanity.censor(data.name);
const tags = data.tags.map((t) => profanity.censor(t));
const description = data.description && profanity.censor(data.description);
const name = profanity.censor(uncensoredName);
const tags = uncensoredTags.map((t) => profanity.censor(t));
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files
const customImages: File[] = [];
for (const img of [data.image1, data.image2, data.image3]) {
for (const img of [image1, image2, image3]) {
if (!img) continue;
const imageValidation = await validateImage(img);
@ -124,16 +136,16 @@ export async function POST(request: NextRequest) {
}
// Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") {
const imageValidation = await validateImage(data.miiPortraitImage);
if (platform === "SWITCH") {
const imageValidation = await validateImage(miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
if (data.platform === "THREE_DS") {
if (platform === "THREE_DS") {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
@ -145,17 +157,17 @@ export async function POST(request: NextRequest) {
const miiRecord = await prisma.mii.create({
data: {
userId: Number(session.user.id),
platform: data.platform,
platform,
name,
tags,
description,
gender: data.gender ?? "MALE",
gender: gender ?? "MALE",
// Access key only for Switch
accessKey: data.platform === "SWITCH" ? data.accessKey : null,
accessKey: platform === "SWITCH" ? accessKey : null,
// Automatically detect certain information if on 3DS
...(data.platform === "THREE_DS" &&
...(platform === "THREE_DS" &&
conversion && {
firstName: conversion.tomodachiLifeMii.firstName,
lastName: conversion.tomodachiLifeMii.lastName,
@ -174,7 +186,7 @@ export async function POST(request: NextRequest) {
let portraitBuffer: Buffer | undefined;
// Download the image of the Mii (3DS)
if (data.platform === "THREE_DS") {
if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl!);
@ -183,8 +195,8 @@ export async function POST(request: NextRequest) {
}
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
} else if (data.platform === "SWITCH") {
portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer());
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
}
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
@ -200,7 +212,7 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
if (data.platform === "THREE_DS") {
if (platform === "THREE_DS") {
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
@ -235,7 +247,7 @@ export async function POST(request: NextRequest) {
{
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
},
500
500,
);
}
@ -248,7 +260,7 @@ export async function POST(request: NextRequest) {
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
await fs.writeFile(fileLocation, webpBuffer);
})
}),
);
// Update database to tell it how many images exist

View file

@ -25,9 +25,12 @@ body {
--color1: var(--color-amber-50);
--color2: var(--color-amber-100);
background-image: repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)),
background-image:
repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)),
repeating-linear-gradient(45deg, var(--color1) 25%, var(--color2) 25%, var(--color2) 75%, var(--color1) 75%, var(--color1));
background-position: 0 0, 10px 10px;
background-position:
0 0,
10px 10px;
background-size: 20px 20px;
}
@ -41,14 +44,17 @@ body {
.button:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
}
.input {
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
}
.input:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
}
.checkbox {
@ -64,7 +70,13 @@ body {
@apply block;
}
/* Tooltips */
.checkbox-alt {
@apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all
after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5
after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px]
checked:hover:bg-orange-500 ml-auto;
}
[data-tooltip] {
@apply relative z-10;
}
@ -82,23 +94,6 @@ body {
@apply opacity-100 scale-100;
}
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
/* Scrollbars */
/* Firefox */
* {

View file

@ -28,6 +28,18 @@ export default async function LoginPage() {
</div>
<LoginButtons />
<p className="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "}
<a href="/terms-of-service" className="underline hover:text-zinc-600">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" className="underline hover:text-zinc-600">
Privacy Policy
</a>
.
</p>
</div>
</div>
);

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

@ -239,6 +239,7 @@ export default async function MiiPage({ params }: Props) {
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
{/* Like button */}
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
</div>
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">

View file

@ -1,10 +1,12 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { Icon } from "@iconify/react";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import Countdown from "@/components/countdown";
import MiiList from "@/components/mii-list";
import Skeleton from "@/components/mii-list/skeleton";
@ -35,7 +37,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
export default async function Page({ searchParams }: Props) {
const session = await auth();
const { tags } = await searchParams;
const { page, tags } = await searchParams;
if (session?.user && !session.user.username) {
redirect("/create-username");
@ -54,6 +56,21 @@ export default async function Page({ searchParams }: Props) {
<>
<h1 className="sr-only">{tags ? `Miis tagged with '${tags}' - TomodachiShare` : "TomodachiShare - index mii list"}</h1>
{(!page || page === "1") && (
<div className="flex items-center justify-center gap-2 mb-2 max-sm:flex-col">
<a
href="https://discord.gg/48cXBFKvWQ"
className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-4 py-2.5 flex justify-center items-center gap-4 w-fit"
>
<Icon icon="ic:baseline-discord" fontSize={48} className="text-indigo-400" />
<div>
<p className="text-xl font-bold">Join the Discord</p>
<p className="text-sm">Code: 48cXBFKvWQ</p>
</div>
</a>
<Countdown />
</div>
)}
<Suspense fallback={<Skeleton />}>
<MiiList searchParams={await searchParams} />
</Suspense>

View file

@ -32,8 +32,13 @@ export default async function SubmitPage() {
if (activePunishment) redirect("/off-the-island");
// Check if submissions are disabled
let value: boolean | null = true;
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
value = await response.json();
} catch (error) {
return <p>An error occurred!</p>;
}
if (!value)
return (

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 [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel();
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
@ -44,7 +44,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
// Keep order of images whilst opening at src prop
const index = images.indexOf(src);
if (index !== -1) {
emblaApi.scrollTo(index);
emblaApi.scrollTo(index, true);
setSelectedIndex(index);
}
@ -80,83 +80,74 @@ export default function ImageViewer({ src, alt, width, height, className, images
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl mx-4 shadow-lg aspect-square w-full max-w-xl relative transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
<button
type="button"
aria-label="Close"
onClick={close}
className={`pill button p-2! aspect-square text-2xl absolute top-4 right-4 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<div className="z-50 absolute right-0 bg-amber-500 rounded-tr-xl rounded-bl-md p-1 flex justify-between items-center">
<button type="button" aria-label="Close" onClick={close} className="text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
<div className="flex h-full items-center">
<div
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
ref={emblaRef}
>
<div className="flex h-full">
{imagesMap.map((image, index) => (
<div key={index} className="shrink-0 w-full">
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
<Image
src={image}
alt={alt}
width={576}
height={576}
className="object-contain"
width={896}
height={896}
priority={index === selectedIndex}
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
className="max-w-full max-h-full object-contain drop-shadow-lg"
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
/>
</div>
))}
</div>
</div>
</div>
{images.length != 0 && (
{images.length > 1 && (
<>
{/* Carousel buttons */}
{/* Prev button */}
{/* Carousel counter */}
<div
className={`z-50 absolute left-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{selectedIndex + 1} / {images.length}
</div>
{/* Carousel buttons */}
{/* Prev button */}
<button
type="button"
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
disabled={!emblaApi?.canScrollPrev()}
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
</div>
{/* Next button */}
<div
className={`z-50 absolute right-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
<button
type="button"
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
disabled={!emblaApi?.canScrollNext()}
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-right" />
</button>
</div>
{/* Carousel snaps */}
<div
className={`z-50 flex justify-center gap-3 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
className={`flex justify-center gap-2 bg-orange-300/25 p-2.5 rounded-full border border-orange-300 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
@ -165,14 +156,14 @@ export default function ImageViewer({ src, alt, width, height, className, images
key={index}
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-2.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-orange-300 w-8" : "bg-orange-300/40"}`}
/>
))}
</div>
</>
)}
</div>,
document.body
document.body,
)}
</>
);

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { redirect } from "next/navigation";
import { useRouter } from "next/navigation";
import { Icon, loadIcons } from "@iconify/react";
import { abbreviateNumber } from "@/lib/abbreviation";
@ -16,13 +16,18 @@ interface Props {
}
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
const router = useRouter();
const [isLikedState, setIsLikedState] = useState(isLiked);
const [likesState, setLikesState] = useState(likes);
const [isAnimating, setIsAnimating] = useState(false);
const onClick = async () => {
if (disabled) return;
if (!isLoggedIn) redirect("/login");
if (!isLoggedIn) {
router.push("/login");
return;
}
setIsLikedState(!isLikedState);
setLikesState(isLikedState ? likesState - 1 : likesState + 1);

View file

@ -4,11 +4,11 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import { MiiGender, MiiPlatform } from "@prisma/client";
import { MiiGender } from "@prisma/client";
import TagFilter from "./tag-filter";
import PlatformSelect from "./platform-select";
import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters";
export default function FilterMenu() {
const searchParams = useSearchParams();
@ -17,8 +17,9 @@ export default function FilterMenu() {
const [isVisible, setIsVisible] = useState(false);
const rawTags = searchParams.get("tags") || "";
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const rawExclude = searchParams.get("exclude") || "";
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo(
() =>
@ -28,7 +29,17 @@ export default function FilterMenu() {
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
[rawTags],
);
const exclude = useMemo(
() =>
rawExclude
? rawExclude
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawExclude],
);
const [filterCount, setFilterCount] = useState(tags.length);
@ -49,45 +60,56 @@ export default function FilterMenu() {
// Count all active filters
useEffect(() => {
let count = tags.length;
if (platform) count++;
let count = tags.length + exclude.length;
if (gender) count++;
if (allowCopying) count++;
setFilterCount(count);
}, [tags, platform, gender]);
}, [tags, exclude, gender, allowCopying]);
return (
<div className="relative">
<button className="pill button gap-2" onClick={handleClick}>
<Icon icon="mdi:filter" className="text-xl" />
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
Filter
<span className="w-5">({filterCount})</span>
</button>
{isOpen && (
<div
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`}
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
>
{/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Include</span>
<hr className="grow border-zinc-300" />
</div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Exclude</span>
<hr className="grow border-zinc-300" />
</div>
<PlatformSelect />
<TagFilter isExclude />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Other</span>
<hr className="grow border-zinc-300" />
</div>
<OtherFilters />
</div>
)}
</div>

View file

@ -28,7 +28,7 @@ export default function GenderSelect() {
}
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 { prisma } from "@/lib/prisma";
import FilterMenu from "./filter-menu";
import SortSelect from "./sort-select";
import Carousel from "../carousel";
import LikeButton from "../like-button";
import DeleteMiiButton from "../delete-mii";
import Pagination from "./pagination";
import FilterMenu from "./filter-menu";
interface Props {
searchParams: { [key: string]: string | string[] | undefined };
@ -29,7 +29,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -51,10 +51,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
// Profiles
...(userId && { userId }),
};
@ -76,6 +79,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
tags: true,
createdAt: true,
gender: true,
allowedCopying: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
@ -96,9 +100,6 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") {
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
@ -106,10 +107,12 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
});
totalCount = matchingIds.length;
filteredCount = Math.min(matchingIds.length, limit);
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
if (matchingIds.length === 0) return;
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
@ -119,7 +122,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}
// Convert to number[] array
const selectedIds = matchingIds.slice(0, limit).map((i) => i.id);
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
list = await prisma.mii.findMany({
where: {
@ -194,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel
images={[
`/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
`/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>

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(() => {
router.push(`?${params.toString()}`);
router.push(`?${params.toString()}`, { scroll: false });
});
},
});
@ -54,11 +54,7 @@ export default function SortSelect() {
>
{isOpen &&
items.map((item, index) => (
<li
key={item}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
{item}
</li>
))}

View file

@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../tag-selector";
export default function TagFilter() {
interface Props {
isExclude?: boolean;
}
export default function TagFilter({ isExclude }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const rawTags = searchParams.get("tags") || "";
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
const preexistingTags = useMemo(
() =>
rawTags
@ -18,7 +22,7 @@ export default function TagFilter() {
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
[rawTags],
);
const [tags, setTags] = useState<string[]>(preexistingTags);
@ -39,20 +43,20 @@ export default function TagFilter() {
params.set("page", "1");
if (tags.length > 0) {
params.set("tags", stateTags);
params.set(isExclude ? "exclude" : "tags", stateTags);
} else {
params.delete("tags");
params.delete(isExclude ? "exclude" : "tags");
}
startTransition(() => {
router.push(`?${params.toString()}`);
router.push(`?${params.toString()}`, { scroll: false });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
return (
<div className="w-72">
<TagSelector tags={tags} setTags={setTags} />
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
</div>
);
}

View file

@ -1,24 +1,28 @@
"use client";
import { redirect, useSearchParams } from "next/navigation";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Icon } from "@iconify/react";
import { querySchema } from "@/lib/schemas";
export default function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState("");
const handleSearch = () => {
const result = querySchema.safeParse(query);
if (!result.success) redirect("/");
if (!result.success) {
router.push("/", { scroll: false });
return;
}
// Clone current search params and add query param
const params = new URLSearchParams(searchParams.toString());
params.set("q", query);
params.set("page", "1");
redirect(`/?${params.toString()}`);
router.push(`/?${params.toString()}`, { scroll: false });
};
const handleKeyDown = (event: React.KeyboardEvent) => {

View file

@ -34,7 +34,7 @@ export default function SubmitForm() {
if (files.length >= 3) return;
setFiles((prev) => [...prev, ...acceptedFiles]);
},
[files.length]
[files.length],
);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
@ -104,7 +104,7 @@ export default function SubmitForm() {
const { id, error } = await response.json();
if (!response.ok) {
setError(error);
setError(String(error)); // app can crash if error message is not a string
return;
}

View file

@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Webcam from "react-webcam";
import jsQR from "jsqr";
import { Icon } from "@iconify/react";
@ -17,14 +16,12 @@ interface Props {
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(
null
);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const requestRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@ -42,8 +39,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
selectedItem,
} = useSelect({
items: cameraItems,
selectedItem:
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedDeviceId(selectedItem?.value ?? null);
},
@ -55,12 +51,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
// Continue scanning in a loop
requestRef.current = requestAnimationFrame(scanQRCode);
const webcam = webcamRef.current;
const video = videoRef.current;
const canvas = canvasRef.current;
if (!webcam || !canvas) return;
const video = webcam.video;
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
@ -69,14 +62,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
const imageData = ctx.getImageData(
0,
0,
video.videoWidth,
video.videoHeight
);
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code) return;
if (!code || !code.binaryData) return;
// Cancel animation frame to stop scanning
if (requestRef.current) {
@ -84,15 +72,20 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
requestRef.current = null;
}
setQrBytesRaw(code.binaryData!);
setQrBytesRaw(code.binaryData);
setIsOpen(false);
}, [isOpen, setIsOpen, setQrBytesRaw]);
const requestPermission = async () => {
const requestPermission = () => {
if (!navigator.mediaDevices) return;
navigator.mediaDevices
.getUserMedia({ video: true })
.getUserMedia({ video: true, audio: false })
.then(() => setPermissionGranted(true))
.catch(() => setPermissionGranted(false));
.catch((err) => {
setPermissionGranted(false);
console.error("An error occurred trying to access the camera", err);
});
};
const close = () => {
@ -106,34 +99,50 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
requestPermission();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
requestPermission();
if (!isOpen || !permissionGranted) return;
if (!navigator.mediaDevices.enumerateDevices) return;
navigator.mediaDevices.enumerateDevices().then((devices) => {
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
if (!selectedDeviceId && videoDevices.length > 0) {
setSelectedDeviceId(videoDevices[0].deviceId);
}
});
}, [isOpen, selectedDeviceId]);
useEffect(() => {
if (!isOpen || !permissionGranted) return;
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
if (!targetDeviceId) return;
setSelectedDeviceId(targetDeviceId);
// start camera stream
return navigator.mediaDevices.getUserMedia({
video: { deviceId: targetDeviceId },
audio: false,
});
})
.then((stream) => {
if (!stream || !videoRef.current) return;
videoRef.current.srcObject = stream;
videoRef.current.play();
})
.catch((err) => console.error("Camera error", err));
requestRef.current = requestAnimationFrame(scanQRCode);
// cleanup
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
if (videoRef.current?.srcObject) {
const stream = videoRef.current.srcObject as MediaStream;
stream.getTracks().forEach((track) => track.stop());
videoRef.current.srcObject = null;
}
};
}, [isOpen, permissionGranted, scanQRCode]);
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
if (!isOpen) return null;
@ -141,9 +150,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/>
<div
@ -153,12 +160,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button
type="button"
aria-label="Close"
onClick={close}
className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"
>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
@ -191,9 +193,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${
highlightedIndex === index ? "bg-black/15" : ""
}`}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
@ -204,51 +204,19 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">
Camera access denied
</p>
<p className="text-gray-600">
Please allow camera access in your browser settings to scan QR
codes
</p>
<button
type="button"
onClick={requestPermission}
className="pill button text-xs mt-2 py-0.5! px-2!"
>
{!permissionGranted && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId
? { exact: selectedDeviceId }
: undefined,
...(selectedDeviceId
? {}
: { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices =
await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter(
(d) => d.kind === "videoinput"
);
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
)}
<video ref={videoRef} className="size-full object-cover rounded-2xl border-2 border-amber-500" />
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">

View file

@ -8,32 +8,18 @@ interface Props {
tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
isExclude?: boolean;
}
const tagRegex = /^[a-z0-9-_]*$/;
const predefinedTags = [
"anime",
"art",
"cartoon",
"celebrity",
"games",
"history",
"meme",
"movie",
"oc",
"tv",
];
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) {
const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] =>
predefinedTags
.filter((item) =>
item.toLowerCase().includes(inputValue?.toLowerCase() || "")
)
.filter((item) => !tags.includes(item));
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
const filteredItems = getFilteredItems();
const isMaxItemsSelected = tags.length >= 8;
@ -49,37 +35,39 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
setTags(tags.filter((t) => t !== tag));
};
const {
isOpen,
openMenu,
getToggleButtonProps,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
} = useCombobox<string>({
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
inputValue,
items: filteredItems,
selectedItem: null,
onInputValueChange: ({ inputValue }) => {
if (inputValue && !tagRegex.test(inputValue)) return;
setInputValue(inputValue || "");
const newValue = inputValue || "";
if (newValue && !tagRegex.test(newValue)) return;
setInputValue(newValue);
},
onStateChange: ({ type, selectedItem }) => {
onSelectedItemChange: ({ type, selectedItem }) => {
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
addTag(selectedItem);
setInputValue("");
}
},
stateReducer: (_, { type, changes }) => {
// Prevent input from being filled when item is selected
if (type === useCombobox.stateChangeTypes.ItemClick) {
return {
...changes,
inputValue: "",
};
}
return changes;
},
});
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
addTag(inputValue);
setInputValue("");
}
} else if (event.key === "Backspace" && inputValue === "") {
// Spill onto last tag
if (event.key === "Backspace" && inputValue === "") {
const lastTag = tags[tags.length - 1];
setInputValue(lastTag);
removeTag(lastTag);
@ -104,10 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
{/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full">
{tags.map((tag) => (
<span
key={tag}
className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"
>
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
{tag}
<button
type="button"
@ -137,17 +122,9 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
</div>
{/* Control buttons */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{hasSelectedItems && (
<button
type="button"
aria-label="Remove All Tags"
className="text-black cursor-pointer"
onClick={() => setTags([])}
>
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
</button>
)}
@ -176,9 +153,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
<li
key={item}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${
highlightedIndex === index ? "bg-black/15" : ""
}`}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item}
</li>
@ -202,9 +177,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
{showTagLimit && (
<div className="mt-1.5 text-xs min-h-4">
{isMaxItemsSelected ? (
<span className="text-red-400 font-medium">
Maximum of 8 tags reached. Remove a tag to add more.
</span>
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
) : (
<span className="text-black/60">{tags.length}/8 tags</span>
)}

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";
@ -11,78 +12,34 @@ export default class ExtendedBitStream extends BitStream {
this.bigEndian = !this.bigEndian;
}
// the type definition for BitStream does not include the _index property
// since it's supposed to be private, but it's needed 4 times here sooo
public alignByte(): void {
// @ts-expect-error _index is private
const nextClosestByteIndex = 8 * Math.ceil(this._index / 8);
// @ts-expect-error _index is private
const bitDistance = nextClosestByteIndex - this._index;
const nextClosestByteIndex = 8 * Math.ceil(this.index / 8);
const bitDistance = nextClosestByteIndex - this.index;
this.skipBits(bitDistance);
}
public bitSeek(bitPos: number): void {
// @ts-expect-error _index is private
this._index = bitPos;
}
public skipBits(bitCount: number): void {
// @ts-expect-error _index is private
this._index += bitCount;
}
public skipBytes(bytes: number): void {
const bits = bytes * 8;
this.skipBits(bits);
this.index += bitCount;
}
public skipBit(): void {
this.skipBits(1);
}
public skipInt8(): void {
this.skipBytes(1);
}
public skipInt16(): void {
// Skipping a uint16 is the same as skipping 2 uint8's
this.skipBytes(2);
this.skipBits(16);
}
public readBit(): number {
return this.readBits(1);
}
public readBytes(length: number): number[] {
return Array(length)
.fill(0)
.map(() => this.readUint8());
return this.readBits(1, false);
}
public readBuffer(length: number): Buffer {
return Buffer.from(this.readBytes(length));
return Buffer.from(super.readBytes(length));
}
public readUTF16String(length: number): string {
return this.readBuffer(length).toString("utf16le").replace(/\0.*$/, "");
}
public writeBit(bit: number): void {
this.writeBits(bit, 1);
}
public writeBuffer(buffer: Buffer): void {
buffer.forEach((byte) => this.writeUint8(byte));
}
public writeUTF16String(string: string): void {
const stringBuffer = Buffer.from(string, "utf16le");
const terminatedBuffer = Buffer.alloc(0x14);
stringBuffer.copy(terminatedBuffer);
this.writeBuffer(terminatedBuffer);
}
}

View file

@ -66,7 +66,14 @@ const STUDIO_RENDER_CLOTHES_COLORS = [
"black",
];
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = [
"none",
"zerox",
"flipx",
"camera",
"offset",
"set",
];
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
@ -74,6 +81,7 @@ const STUDIO_BG_COLOR_REGEX = /^[0-9A-F]{8}$/; // Mii Studio does not allow lowe
export default class Mii {
public bitStream: ExtendedBitStream;
public buffer: Buffer;
// Mii data
// can be sure that these are all initialized in decode()
@ -150,92 +158,292 @@ export default class Mii {
public checksum!: number;
constructor(buffer: Buffer) {
this.buffer = buffer;
this.bitStream = new ExtendedBitStream(buffer);
this.decode();
}
public validate(): void {
// Size check
assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
assert.equal(
this.bitStream.length / 8,
0x60,
`Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`,
);
// Value range and type checks
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
assert.ok(Util.inRange(this.regionLock, Util.range(4)), `Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`);
assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
assert.ok(Util.inRange(this.deviceOrigin, Util.range(1, 5)), `Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`);
assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
assert.equal(typeof this.isValid, "boolean", `Invalid Mii valid flag. Got ${this.isValid}, expected true or false`);
assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
assert.ok(
this.version === 0 || this.version === 3,
`Invalid Mii version. Got ${this.version}, expected 0 or 3`,
);
assert.equal(
typeof this.allowCopying,
"boolean",
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`,
);
assert.equal(
typeof this.profanityFlag,
"boolean",
`Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`,
);
assert.ok(
Util.inRange(this.regionLock, Util.range(4)),
`Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`,
);
assert.ok(
Util.inRange(this.characterSet, Util.range(4)),
`Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`,
);
assert.ok(
Util.inRange(this.pageIndex, Util.range(10)),
`Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`,
);
assert.ok(
Util.inRange(this.slotIndex, Util.range(10)),
`Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`,
);
assert.equal(
this.unknown1,
0,
`Invalid Mii unknown1. Got ${this.unknown1}, expected 0`,
);
assert.ok(
Util.inRange(this.deviceOrigin, Util.range(1, 5)),
`Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`,
);
assert.equal(
this.systemId.length,
8,
`Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`,
);
assert.equal(
typeof this.normalMii,
"boolean",
`Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`,
);
assert.equal(
typeof this.dsMii,
"boolean",
`Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`,
);
assert.equal(
typeof this.nonUserMii,
"boolean",
`Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`,
);
assert.equal(
typeof this.isValid,
"boolean",
`Invalid Mii valid flag. Got ${this.isValid}, expected true or false`,
);
assert.ok(
this.creationTime < 268435456,
`Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`,
);
assert.equal(
this.consoleMAC.length,
6,
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`,
);
assert.ok(
Util.inRange(this.gender, Util.range(2)),
`Invalid Mii gender. Got ${this.gender}, expected 0 or 1`,
);
assert.ok(
Util.inRange(this.birthMonth, Util.range(13)),
`Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`,
);
assert.ok(
Util.inRange(this.birthDay, Util.range(32)),
`Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`,
);
assert.ok(
Util.inRange(this.favoriteColor, Util.range(12)),
`Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`,
);
assert.equal(
typeof this.favorite,
"boolean",
`Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`,
);
assert.ok(
Buffer.from(this.miiName, "utf16le").length <= 0x14,
`Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`,
);
assert.ok(
Util.inRange(this.height, Util.range(128)),
`Invalid Mii height. Got ${this.height}, expected 0-127`,
);
assert.ok(
Util.inRange(this.build, Util.range(128)),
`Invalid Mii build. Got ${this.build}, expected 0-127`,
);
assert.equal(
typeof this.disableSharing,
"boolean",
`Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`,
);
assert.ok(
Util.inRange(this.faceType, Util.range(12)),
`Invalid Mii face type. Got ${this.faceType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.skinColor, Util.range(7)),
`Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`,
);
assert.ok(
Util.inRange(this.wrinklesType, Util.range(12)),
`Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.makeupType, Util.range(12)),
`Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.hairType, Util.range(132)),
`Invalid Mii hair type. Got ${this.hairType}, expected 0-131`,
);
assert.ok(Util.inRange(this.gender, Util.range(2)), `Invalid Mii gender. Got ${this.gender}, expected 0 or 1`);
assert.ok(Util.inRange(this.birthMonth, Util.range(13)), `Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`);
assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
assert.equal(typeof this.favorite, "boolean", `Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`);
assert.ok(Buffer.from(this.miiName, "utf16le").length <= 0x14, `Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`);
assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
assert.equal(typeof this.disableSharing, "boolean", `Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`);
assert.ok(Util.inRange(this.faceType, Util.range(12)), `Invalid Mii face type. Got ${this.faceType}, expected 0-11`);
assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
assert.ok(Util.inRange(this.makeupType, Util.range(12)), `Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`);
assert.ok(Util.inRange(this.hairType, Util.range(132)), `Invalid Mii hair type. Got ${this.hairType}, expected 0-131`);
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
assert.ok(Util.inRange(this.eyeVerticalStretch, Util.range(7)), `Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`);
assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
assert.equal(
typeof this.flipHair,
"boolean",
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`,
);
assert.ok(
Util.inRange(this.eyeType, Util.range(60)),
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`,
);
assert.ok(
Util.inRange(this.eyeColor, Util.range(6)),
`Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`,
);
assert.ok(
Util.inRange(this.eyeScale, Util.range(8)),
`Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`,
);
assert.ok(
Util.inRange(this.eyeVerticalStretch, Util.range(7)),
`Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.eyeRotation, Util.range(8)),
`Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`,
);
assert.ok(
Util.inRange(this.eyeSpacing, Util.range(13)),
`Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`,
);
assert.ok(
Util.inRange(this.eyeYPosition, Util.range(19)),
`Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.eyebrowType, Util.range(25)),
`Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`,
);
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
assert.ok(
Util.inRange(this.eyebrowScale, Util.range(9)),
`Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.eyebrowRotation, Util.range(12)),
`Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`,
);
assert.ok(
Util.inRange(this.eyebrowSpacing, Util.range(13)),
`Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`,
);
assert.ok(
Util.inRange(this.eyebrowYPosition, Util.range(3, 19)),
`Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`,
);
assert.ok(
Util.inRange(this.noseType, Util.range(18)),
`Invalid Mii nose type. Got ${this.noseType}, expected 0-17`,
);
assert.ok(
Util.inRange(this.noseScale, Util.range(9)),
`Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.noseYPosition, Util.range(19)),
`Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.mouthType, Util.range(36)),
`Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`,
);
assert.ok(
Util.inRange(this.mouthColor, Util.range(5)),
`Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`,
);
assert.ok(
Util.inRange(this.mouthScale, Util.range(9)),
`Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`,
);
assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
assert.ok(Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), `Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`);
assert.ok(Util.inRange(this.noseType, Util.range(18)), `Invalid Mii nose type. Got ${this.noseType}, expected 0-17`);
assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.mouthType, Util.range(36)), `Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`);
assert.ok(Util.inRange(this.mouthColor, Util.range(5)), `Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`);
assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
assert.ok(
Util.inRange(this.mouthHorizontalStretch, Util.range(7)),
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.mouthYPosition, Util.range(19)),
`Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.mustacheType, Util.range(6)),
`Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`,
);
assert.ok(
Util.inRange(this.beardType, Util.range(6)),
`Invalid Mii beard type. Got ${this.beardType}, expected 0-5`,
);
assert.ok(Util.inRange(this.mouthYPosition, Util.range(19)), `Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.mustacheType, Util.range(6)), `Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`);
assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
assert.ok(Util.inRange(this.glassesColor, Util.range(6)), `Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`);
assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
assert.ok(Util.inRange(this.moleScale, Util.range(9)), `Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`);
assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
assert.ok(
Util.inRange(this.mustacheScale, Util.range(9)),
`Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.mustacheYPosition, Util.range(17)),
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`,
);
assert.ok(
Util.inRange(this.glassesType, Util.range(9)),
`Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`,
);
assert.ok(
Util.inRange(this.glassesColor, Util.range(6)),
`Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`,
);
assert.ok(
Util.inRange(this.glassesScale, Util.range(8)),
`Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`,
);
assert.ok(
Util.inRange(this.glassesYPosition, Util.range(21)),
`Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`,
);
assert.equal(
typeof this.moleEnabled,
"boolean",
`Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`,
);
assert.ok(
Util.inRange(this.moleScale, Util.range(9)),
`Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.moleXPosition, Util.range(17)),
`Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`,
);
assert.ok(
Util.inRange(this.moleYPosition, Util.range(31)),
`Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`,
);
// Sanity checks
/*
@ -251,7 +459,10 @@ export default class Mii {
}
*/
if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
if (
this.nonUserMii &&
(this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)
) {
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
}
@ -357,10 +568,12 @@ export default class Mii {
}
public calculateCRC(): number {
const view = this.bitStream.view;
// @ts-expect-error _view is private
const data = view._view.subarray(0, 0x5e);
// #view is inaccessible
const data = new Uint8Array(
this.buffer.buffer,
this.buffer.byteOffset,
this.buffer.length,
).subarray(0, 0x5e);
let crc = 0x0000;
@ -506,7 +719,7 @@ export default class Mii {
instanceCount?: number;
instanceRotationMode?: string;
data?: string;
} = STUDIO_RENDER_DEFAULTS
} = STUDIO_RENDER_DEFAULTS,
): string {
const params = {
...STUDIO_RENDER_DEFAULTS,
@ -514,11 +727,23 @@ export default class Mii {
data: this.encodeStudio().toString("hex"),
};
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
params.type = STUDIO_RENDER_TYPES.includes(params.type as string)
? params.type
: STUDIO_RENDER_DEFAULTS.type;
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(
params.expression as string,
)
? params.expression
: STUDIO_RENDER_DEFAULTS.expression;
params.width = Util.clamp(params.width, 512);
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string)
? params.bgColor
: STUDIO_RENDER_DEFAULTS.bgColor;
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(
params.clothesColor,
)
? params.clothesColor
: STUDIO_RENDER_DEFAULTS.clothesColor;
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
@ -528,16 +753,25 @@ export default class Mii {
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(
params.lightDirectionMode,
)
? params.lightDirectionMode
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
params.instanceRotationMode =
STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
params.instanceRotationMode,
)
? params.instanceRotationMode
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
// converts non-string params to strings
const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
const query = new URLSearchParams(
Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value.toString()]),
),
);
if (params.lightDirectionMode === "none") {
query.delete("lightDirectionMode");

View file

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { Redis } from "ioredis";
import { createClient, RedisClientType } from "redis";
import { auth } from "./auth";
const redis = new Redis(process.env.REDIS_URL!);
const windowSize = 60;
const WINDOW_SIZE = 60;
let client: RedisClientType | null = null;
interface RateLimitData {
success: boolean;
@ -12,6 +12,17 @@ interface RateLimitData {
expires: number;
}
async function getRedisClient() {
if (!client) {
client = createClient({
url: process.env.REDIS_URL,
});
client.on("error", (err) => console.error("Redis client error", err));
await client.connect();
}
return client;
}
// Fixed window implementation
export class RateLimit {
private request: NextRequest;
@ -37,22 +48,19 @@ export class RateLimit {
const now = Date.now();
const seconds = Math.floor(now / 1000);
const currentWindow = Math.floor(seconds / windowSize) * windowSize;
const expireAt = currentWindow + windowSize;
const currentWindow = Math.floor(seconds / WINDOW_SIZE) * WINDOW_SIZE;
const expireAt = currentWindow + WINDOW_SIZE;
try {
// Create a Redis transaction
const tx = redis.multi();
tx.incr(key);
tx.expireat(key, expireAt);
const client = await getRedisClient();
// Execute transaction and get the count
const results = await tx.exec();
if (!results) {
// Execute a Redis transaction and get the count
const [result] = await client.multi().incr(key).expireAt(key, expireAt).exec();
if (!result) {
throw new Error("Redis transaction failed");
}
const count = results[0][1] as number;
const count = result as unknown as number;
const success = count <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - count);

View file

@ -30,7 +30,7 @@ export const tagsSchema = z
.max(20, { error: "Tags cannot be more than 20 characters long" })
.regex(/^[a-z0-9-_]+$/, {
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
})
}),
)
.min(1, { error: "There must be at least 1 tag" })
.max(8, { error: "There cannot be more than 8 tags" });
@ -47,10 +47,20 @@ export const searchSchema = z.object({
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
.filter((tag) => tag.length > 0),
),
exclude: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce