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

This commit is contained in:
trafficlunar 2026-01-02 15:14:06 +00:00
commit 2af1bf18a6
78 changed files with 3287 additions and 2325 deletions

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"useTabs": true,
"printWidth": 160
}

129
API.md Normal file
View file

@ -0,0 +1,129 @@
# 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",
"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

@ -2,8 +2,6 @@
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety. Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
Note: this project is intended to be used on Linux - in production and development.
## Getting started ## Getting started
To get the project up and running locally, follow these steps: To get the project up and running locally, follow these steps:
@ -14,7 +12,7 @@ $ cd tomodachi-share
$ pnpm install $ pnpm install
``` ```
Prisma types are generated automatically post-install, which is quite convenient. However, sometimes you might need to: Prisma types are generated automatically, however, sometimes you might need to:
```bash ```bash
# Generate Prisma client types # Generate Prisma client types
@ -25,6 +23,12 @@ $ pnpm prisma migrate dev
$ pnpm prisma generate $ pnpm prisma generate
``` ```
I recommend opting out of Next.js' telemetry program but it is not a requirement.
```bash
$ pnpm exec next telemetry disable
```
## Environment variables ## Environment variables
You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`: You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`:
@ -53,7 +57,13 @@ services:
- 6379:6379 - 6379:6379
``` ```
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest. After starting the docker applications, apply TomodachiShare's database schema migrations.
```bash
$ pnpm prisma migrate dev
```
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest of the variables.
For the `AUTH_SECRET`, run the following in the command line: For the `AUTH_SECRET`, run the following in the command line:
@ -61,7 +71,10 @@ For the `AUTH_SECRET`, run the following in the command line:
$ pnpx auth secret $ pnpx auth secret
``` ```
Now, let's get the Discord and GitHub authentication set up. > [!NOTE]
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `.env`
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
For Discord, create an application in the developer portal, go to 'OAuth2', copy in the Client ID and Secret into the respective variables and also add this as a redirect URL: `http://localhost:3000/api/auth/callback/discord`. For Discord, create an application in the developer portal, go to 'OAuth2', copy in the Client ID and Secret into the respective variables and also add this as a redirect URL: `http://localhost:3000/api/auth/callback/discord`.

View file

@ -25,7 +25,9 @@
- 🌎 Browse and add Miis from other players - 🌎 Browse and add Miis from other players
- 🏝️ Build your perfect island by finding the perfect residents - 🏝️ Build your perfect island by finding the perfect residents
### <a href="/DEVELOPMENT.MD">Development Instructions</a> ### <a href="/DEVELOPMENT.md">Development Instructions</a>
### <a href="/API.md">API Reference</a>
--- ---

View file

@ -2,9 +2,9 @@
"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.24.0",
"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",
@ -12,49 +12,49 @@
"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.1",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.2.5",
"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.1.1",
"ioredis": "^5.7.0", "ioredis": "^5.8.2",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "15.5.3", "next": "16.0.10",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.1.1", "react": "^19.2.3",
"react-dom": "^19.1.1", "react-dom": "^19.2.3",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0", "react-webcam": "^7.2.0",
"satori": "^0.18.2", "satori": "^0.18.3",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"sharp": "^0.34.3", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.3.6", "swr": "^2.3.7",
"zod": "^4.1.8" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.3",
"@iconify/react": "^6.0.1", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.18",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.3.1", "@types/node": "^25.0.2",
"@types/react": "^19.1.12", "@types/react": "^19.2.7",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^9.35.0", "eslint": "^9.39.2",
"eslint-config-next": "15.5.3", "eslint-config-next": "16.0.10",
"prisma": "^6.16.1", "prisma": "^6.19.1",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.18",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"vitest": "^3.2.4" "vitest": "^4.0.15"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);

View file

@ -14,6 +14,7 @@ model User {
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
description String? @db.VarChar(256)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form"; import BannerForm from "@/components/admin/banner-form";
import ControlCenter from "@/components/admin/control-center"; import ControlCenter from "@/components/admin/control-center";
import RegenerateImagesButton from "@/components/admin/regenerate-images";
import UserManagement from "@/components/admin/user-management"; import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports"; import Reports from "@/components/admin/reports";
@ -31,36 +32,37 @@ export default async function AdminPage() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Banners</span> <span>Banners</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<BannerForm /> <BannerForm />
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Control Center</span> <span>Control Center</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<ControlCenter /> <ControlCenter />
<RegenerateImagesButton />
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>User Management</span> <span>User Management</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<UserManagement /> <UserManagement />
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Reports</span> <span>Reports</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<Reports /> <Reports />

View file

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateMetadataImage } from "@/lib/images";
export async function PATCH() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Start processing in background
regenerateImages().catch(console.error);
return NextResponse.json({ success: true });
}
async function regenerateImages() {
// Get miis in batches to reduce memory usage
const BATCH_SIZE = 10;
const totalMiis = await prisma.mii.count();
let processed = 0;
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
const miis = await prisma.mii.findMany({
skip,
take: BATCH_SIZE,
include: { user: { select: { name: true } } },
});
// Process each batch sequentially to avoid overwhelming the server
for (const mii of miis) {
try {
await generateMetadataImage(mii, mii.user.name);
processed++;
} catch (error) {
console.error(`Failed to generate image for mii ${mii.id}:`, error);
}
}
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { profanity } from "@2toad/profanity";
import z from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { description } = await request.json();
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
const validation = z.string().trim().max(256).safeParse(description);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { description: profanity.censor(description) },
});
} catch (error) {
console.error("Failed to update description:", error);
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -44,13 +44,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
where: { where: {
id: miiId, id: miiId,
}, },
include: {
user: {
select: {
username: true,
},
},
},
}); });
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404); if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
@ -102,11 +95,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (images.length > 0) updateData.imageCount = images.length; if (images.length > 0) updateData.imageCount = images.length;
if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400); if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
await prisma.mii.update({ const updatedMii = await prisma.mii.update({
where: { where: {
id: miiId, id: miiId,
}, },
data: updateData, data: updateData,
include: {
user: {
select: {
name: true,
},
},
},
}); });
// Only touch files if new images were uploaded // Only touch files if new images were uploaded
@ -137,7 +137,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} else if (description === undefined) { } else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image // If images or description were not changed, regenerate the metadata image
try { try {
await generateMetadataImage(mii, mii.user.username!); await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);

View file

@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { ReportReason, ReportType } from "@prisma/client"; import { Prisma, ReportReason, ReportType } from "@prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { MiiWithUsername } from "@/types";
const reportSchema = z.object({ const reportSchema = z.object({
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }), id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
@ -30,7 +29,15 @@ 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 { id, type, reason, notes } = parsed.data; const { id, type, reason, notes } = parsed.data;
let mii: MiiWithUsername | null = null; let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}> | null = null;
// Check if the Mii or User exists // Check if the Mii or User exists
if (type === "mii") { if (type === "mii") {

View file

@ -0,0 +1,79 @@
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

@ -33,13 +33,16 @@ const submitSchema = z
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// QR code // QR code
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }), qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
}),
// Custom images // Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(), image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(), image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(), image3: z.union([z.instanceof(File), z.any()]).optional(),
}) })
// This refine function is probably useless
.refine( .refine(
(data) => { (data) => {
// If platform is Switch, gender and miiPortraitImage must be present // If platform is Switch, gender and miiPortraitImage must be present
@ -117,8 +120,12 @@ export async function POST(request: NextRequest) {
} }
} }
console.log(data.miiPortraitImage);
// Check Mii portrait image as well (Switch) // Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") { if (data.platform === "SWITCH") {
if (data.miiPortraitImage.length === 0) return rateLimit.sendResponse({ error: "No mii portrait found!" }, 400);
const imageValidation = await validateImage(data.miiPortraitImage); const imageValidation = await validateImage(data.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);
} }
@ -217,10 +224,15 @@ export async function POST(request: NextRequest) {
} }
try { try {
await generateMetadataImage(miiRecord, session.user.username!); await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500); return rateLimit.sendResponse(
{
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
},
500
);
} }
// Compress and store user images // Compress and store user images

View file

@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
} }
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to the island!</h1> <h1 className="text-3xl font-bold mb-4">Welcome to the island!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Please create a username</span> <span>Please create a username</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<UsernameForm /> <UsernameForm />

View file

@ -32,7 +32,7 @@ body {
} }
.pill { .pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md; @apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
} }
.button { .button {
@ -40,15 +40,15 @@ 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;
} }
.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;
} }
.input:disabled { .input:disabled {
@apply text-zinc-600 !bg-zinc-100 !border-zinc-300; @apply text-zinc-600 bg-zinc-100! border-zinc-300!;
} }
.checkbox { .checkbox {
@ -88,7 +88,7 @@ body {
} }
[data-tooltip-span] > .tooltip { [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]; @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 { [data-tooltip-span] > .tooltip::before {

View file

@ -92,7 +92,7 @@ export default function RootLayout({
<Providers> <Providers>
<Header /> <Header />
<AdminBanner /> <AdminBanner />
<main className="px-4 py-8 max-w-7xl w-full flex-grow flex flex-col">{children}</main> <main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer /> <Footer />
</Providers> </Providers>
</body> </body>

View file

@ -17,14 +17,14 @@ export default async function LoginPage() {
} }
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1> <h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Choose your login method</span> <span>Choose your login method</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<LoginButtons /> <LoginButtons />

View file

@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/data");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const data = await prisma.mii.findUnique({
where: { id: miiId },
select: {
id: true,
name: true,
_count: {
select: {
likedBy: true,
},
},
imageCount: true,
tags: true,
description: true,
firstName: true,
lastName: true,
gender: true,
islandName: true,
allowedCopying: true,
createdAt: true,
user: { select: { id: true, username: true, name: true } },
},
});
if (!data) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
const { _count, ...rest } = data;
return rateLimit.sendResponse({
...rest,
likes: _count.likedBy,
});
}

View file

@ -1,4 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@ -8,7 +9,6 @@ import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images"; import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { MiiWithUsername } from "@/types";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
type: z type: z
@ -37,7 +37,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
let buffer: Buffer | undefined; let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata' // Only find Mii if image type is 'metadata'
let mii: MiiWithUsername | null = null; let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
if (imageType === "metadata") { if (imageType === "metadata") {
mii = await prisma.mii.findUnique({ mii = await prisma.mii.findUnique({
@ -47,7 +55,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
include: { include: {
user: { user: {
select: { select: {
username: true, name: true,
}, },
}, },
}, },
@ -68,7 +76,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`); console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
try { try {
buffer = await generateMetadataImage(mii, mii.user.username!); buffer = await generateMetadataImage(mii, mii.user.name!);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);

View file

@ -14,6 +14,7 @@ import DeleteMiiButton from "@/components/delete-mii";
import ShareMiiButton from "@/components/share-mii-button"; import ShareMiiButton from "@/components/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan"; import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan"; import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
import Description from "@/components/description";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -55,7 +56,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl], images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
publishedTime: mii.createdAt.toISOString(), publishedTime: mii.createdAt.toISOString(),
authors: username, authors: username,
}, },
@ -63,7 +69,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`, description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl], images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
creator: username, creator: username,
}, },
alternates: { alternates: {
@ -83,6 +94,7 @@ export default async function MiiPage({ params }: Props) {
include: { include: {
user: { user: {
select: { select: {
name: true,
username: true, username: true,
}, },
}, },
@ -110,13 +122,13 @@ export default async function MiiPage({ params }: Props) {
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1"> <div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2"> <div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */} {/* Mii Image */}
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50"> <div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer <ImageViewer
src={`/mii/${mii.id}/image?type=mii`} src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={200} width={200}
height={200} height={200}
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full" className="drop-shadow-lg hover:scale-105 transition-transform"
/> />
</div> </div>
{/* QR Code */} {/* QR Code */}
@ -144,24 +156,22 @@ export default async function MiiPage({ params }: Props) {
From: <span className="text-right font-medium">{mii.islandName} Island</span> From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li> </li>
<li> <li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" /> Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox cursor-auto!" />
</li> </li>
</ul> </ul>
)} )}
{/* Mii Platform */} {/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}> <div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Platform</span> <span>Platform</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2"> <div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div <div
className={`tooltip !mt-1 ${ className={`tooltip mt-1! ${
mii.platform === "THREE_DS" mii.platform === "THREE_DS" ? "bg-sky-400! border-sky-400! before:border-b-sky-400!" : "bg-red-400! border-red-400! before:border-b-red-400!"
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
}`} }`}
> >
{mii.platform === "THREE_DS" ? "3DS" : "Switch"} {mii.platform === "THREE_DS" ? "3DS" : "Switch"}
@ -186,17 +196,15 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Gender */} {/* Mii Gender */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="flex-grow border-zinc-300" /> <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>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2"> <div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
<div <div
className={`tooltip !mt-1 ${ className={`tooltip mt-1! ${
mii.gender === "MALE" mii.gender === "MALE" ? "bg-blue-400! border-blue-400! before:border-b-blue-400!" : "bg-pink-400! border-pink-400! before:border-b-pink-400!"
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
}`} }`}
> >
{mii.gender === "MALE" ? "Male" : "Female"} {mii.gender === "MALE" ? "Male" : "Female"}
@ -225,15 +233,9 @@ export default async function MiiPage({ params }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
{/* Submission name */} {/* Submission name */}
<h1 className="text-4xl font-extrabold break-words 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 <LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
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">
@ -247,7 +249,7 @@ export default async function MiiPage({ params }: Props) {
{/* Author and Created date */} {/* Author and Created date */}
<div className="mt-2"> <div className="mt-2">
<Link href={`/profile/${mii.userId}`} className="text-lg"> <Link href={`/profile/${mii.userId}`} className="text-lg">
By: <span className="font-bold">@{mii.user.username}</span> By <span className="font-bold">{mii.user.name}</span>
</Link> </Link>
<h4 className="text-sm"> <h4 className="text-sm">
Created:{" "} Created:{" "}
@ -265,7 +267,7 @@ export default async function MiiPage({ params }: Props) {
</div> </div>
{/* Description */} {/* Description */}
{mii.description && <p className="text-sm mt-2 ml-2 bg-white/50 p-3 rounded-lg border border-orange-200">{mii.description}</p>} {mii.description && <Description text={mii.description} className="ml-2" />}
</div> </div>
{/* Buttons */} {/* Buttons */}
@ -302,7 +304,7 @@ export default async function MiiPage({ params }: Props) {
{images.map((src, index) => ( {images.map((src, index) => (
<div <div
key={index} key={index}
className="relative aspect-[3/2] rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30" className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
> >
<Image <Image
src={src} src={src}
@ -317,7 +319,7 @@ export default async function MiiPage({ params }: Props) {
alt="mii screenshot" alt="mii screenshot"
width={256} width={256}
height={170} height={170}
className="aspect-[3/2] w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10" className="aspect-3/2 w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
images={images} images={images}
/> />
</div> </div>

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-7xl font-black">404</h2> <h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p> <p>Page not found - you swam off the island!</p>

View file

@ -51,7 +51,7 @@ export default async function ExiledPage() {
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true)); const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
<h2 className="text-4xl font-black mb-2"> <h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE" {activePunishment.type === "PERM_EXILE"
@ -78,9 +78,9 @@ export default async function ExiledPage() {
</p> </p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Violating Items</span> <span>Violating Items</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col gap-2 p-4"> <div className="flex flex-col gap-2 p-4">

View file

@ -2,6 +2,7 @@ import { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ProfileSettings from "@/components/profile-settings"; import ProfileSettings from "@/components/profile-settings";
import ProfileInformation from "@/components/profile-information"; import ProfileInformation from "@/components/profile-information";
@ -20,10 +21,12 @@ export default async function ProfileSettingsPage() {
if (!session) redirect("/login"); if (!session) redirect("/login");
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } });
return ( return (
<div> <div>
<ProfileInformation page="settings" /> <ProfileInformation page="settings" />
<ProfileSettings /> <ProfileSettings currentDescription={user?.description} />
</div> </div>
); );
} }

View file

@ -16,7 +16,6 @@ export default function robots(): MetadataRoute.Robots {
"/report/mii/*", "/report/mii/*",
"/report/user/*", "/report/user/*",
"/admin", "/admin",
"/_next/image",
], ],
}, },
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`, sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,

View file

@ -3,6 +3,8 @@ import type { MetadataRoute } from "next";
type SitemapRoute = MetadataRoute.Sitemap[0]; type SitemapRoute = MetadataRoute.Sitemap[0];
export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) { if (!baseUrl) {
@ -41,7 +43,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
url: `${baseUrl}/profile/${user.id}`, url: `${baseUrl}/profile/${user.id}`,
lastModified: user.updatedAt, lastModified: user.updatedAt,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.3, priority: 0.2,
} as SitemapRoute) } as SitemapRoute)
), ),
]; ];

View file

@ -37,7 +37,7 @@ export default async function SubmitPage() {
if (!value) if (!value)
return ( return (
<div className="flex-grow flex items-center justify-center"> <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-5xl font-black">Sorry</h2> <h2 className="text-5xl font-black">Sorry</h2>
<p className="mt-1">Submissions are disabled</p> <p className="mt-1">Submissions are disabled</p>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
@ -18,7 +18,7 @@ function RedirectBanner() {
if (from !== "old-domain") return null; if (from !== "old-domain") return null;
return ( return (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start"> <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<Icon icon="humbleicons:link" className="text-2xl min-w-6" /> <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span> <span>We have moved URLs, welcome to tomodachishare.com!</span>
</div> </div>
@ -27,13 +27,39 @@ function RedirectBanner() {
export default function AdminBanner() { export default function AdminBanner() {
const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher); const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
const [shouldShow, setShouldShow] = useState(true);
useEffect(() => {
if (!data?.message) return;
// Check if the current banner text was closed by the user
const closedBanner = window.localStorage.getItem("closedBanner");
setShouldShow(data.message !== closedBanner);
}, [data]);
const handleClose = () => {
if (!data) return;
// Close banner and remember it
window.localStorage.setItem("closedBanner", data.message);
setShouldShow(false);
};
return ( return (
<> <>
{data && data.message && ( {data && data.message && shouldShow && (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start"> <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" /> <div className="flex gap-2 h-full items-center w-fit">
<span>{data.message}</span> <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span>
</div>
<button
onClick={handleClose}
className="min-sm:absolute right-2 cursor-pointer p-1.5"
>
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
</div> </div>
)} )}
<Suspense> <Suspense>

View file

@ -26,7 +26,7 @@ export default function ControlCenter() {
<input <input
name="submit" name="submit"
type="checkbox" type="checkbox"
className="checkbox !size-6" className="checkbox size-6!"
placeholder="Enter banner text" placeholder="Enter banner text"
checked={canSubmit} checked={canSubmit}
onChange={(e) => setCanSubmit(e.target.checked)} onChange={(e) => setCanSubmit(e.target.checked)}

View file

@ -55,7 +55,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <div className="fixed inset-0 w-full 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 ${

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
export default function RegenerateImagesButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
close();
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
Regenerate all Mii metadata images
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Regenerate Images</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} />
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -34,7 +34,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
disabled={hasExpired} disabled={hasExpired}
checked={isChecked} checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)} onChange={(e) => setIsChecked(e.target.checked)}
className={`checkbox ${hasExpired && "text-zinc-600 !bg-zinc-100 !border-zinc-300"}`} className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
/> />
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}> <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree I Agree

View file

@ -238,7 +238,7 @@ export default function Punishments() {
rows={2} rows={2}
maxLength={256} maxLength={256}
placeholder="Type notes here for the punishment..." placeholder="Type notes here for the punishment..."
className="pill input !rounded-xl resize-none" className="pill input rounded-xl! resize-none"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
@ -249,7 +249,7 @@ export default function Punishments() {
rows={2} rows={2}
maxLength={256} maxLength={256}
placeholder="Type profile-related reasons here for the punishment..." placeholder="Type profile-related reasons here for the punishment..."
className="pill input !rounded-xl resize-none" className="pill input rounded-xl! resize-none"
value={reasons} value={reasons}
onChange={(e) => setReasons(e.target.value)} onChange={(e) => setReasons(e.target.value)}
/> />
@ -273,7 +273,7 @@ export default function Punishments() {
value={newMii.reason} value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })} onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
/> />
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square !p-2.5"> <button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
<Icon icon="ic:baseline-plus" className="size-4" /> <Icon icon="ic:baseline-plus" className="size-4" />
</button> </button>
</div> </div>

View file

@ -43,8 +43,8 @@ export default function Carousel({ images, className }: Props) {
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}> <div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex"> <div className="flex">
{images.map((src, index) => ( {images.map((src, index) => (
<div key={index} className="flex-shrink-0 w-full"> <div key={index} className="shrink-0 w-full">
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-[3/2] object-contain" images={images} /> <ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-3/2 object-contain" images={images} />
</div> </div>
))} ))}
</div> </div>

View file

@ -69,7 +69,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--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 ${
@ -107,7 +107,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<button onClick={close} className="pill button"> <button onClick={close} className="pill button">
Cancel Cancel
</button> </button>
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" /> <SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div> </div>
</div> </div>
</div>, </div>,

View file

@ -0,0 +1,83 @@
import Image from "next/image";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
interface Props {
text: string;
className?: string;
}
export default function Description({ text, className }: Props) {
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{/* Adds fancy formatting when linking to other pages on the site */}
{(() => {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
// Match both mii and profile links
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
const parts = text.split(regex);
return parts.map(async (part, index) => {
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
if (miiMatch) {
const id = Number(miiMatch[1]);
const linkedMii = await prisma.mii.findUnique({
where: {
id,
},
});
if (!linkedMii) return;
return (
<Link
key={index}
href={`/mii/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs"
>
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" />
{linkedMii.name}
</Link>
);
}
if (profileMatch) {
const id = Number(profileMatch[1]);
const linkedProfile = await prisma.user.findUnique({
where: {
id,
},
});
if (!linkedProfile) return;
return (
<Link
key={index}
href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
>
<ProfilePicture
src={linkedProfile.image || "/guest.webp"}
width={24}
height={24}
className="bg-white rounded-lg border-r border-orange-400"
/>
{linkedProfile.name}
</Link>
);
}
// Regular text
return <span key={index}>{part}</span>;
});
})()}
</p>
);
}

View file

@ -32,7 +32,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
{...getRootProps()} {...getRootProps()}
onDragOver={() => setIsDraggingOver(true)} onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)} onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none h-full transition-all duration-200 ${ className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl" isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`} }`}
> >

View file

@ -28,10 +28,23 @@ export default function Footer() {
</span> </span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon icon="ic:baseline-discord" className="text-lg" />
Discord
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a <a
href="https://github.com/trafficlunar/tomodachi-share" href="https://github.com/trafficlunar/tomodachi-share"
target="_blank" target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-center gap-1" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-end gap-1"
> >
<Icon icon="mdi:github" className="text-lg" /> <Icon icon="mdi:github" className="text-lg" />
Source Code Source Code

View file

@ -77,7 +77,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--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 ${
@ -99,7 +99,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}> <div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
<div className="flex h-full items-center"> <div className="flex h-full items-center">
{imagesMap.map((image, index) => ( {imagesMap.map((image, index) => (
<div key={index} className="flex-shrink-0 w-full"> <div key={index} className="shrink-0 w-full">
<Image <Image
src={image} src={image}
alt={alt} alt={alt}

View file

@ -9,7 +9,7 @@ export default function LoginButtons() {
<button <button
onClick={() => signIn("discord", { redirectTo: "/create-username" })} onClick={() => signIn("discord", { redirectTo: "/create-username" })}
aria-label="Login with Discord" aria-label="Login with Discord"
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500" className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
> >
<Icon icon="ic:baseline-discord" fontSize={32} /> <Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord Login with Discord
@ -17,7 +17,7 @@ export default function LoginButtons() {
<button <button
onClick={() => signIn("github", { redirectTo: "/create-username" })} onClick={() => signIn("github", { redirectTo: "/create-username" })}
aria-label="Login with GitHub" aria-label="Login with GitHub"
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white" className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
> >
<Icon icon="mdi:github" fontSize={32} /> <Icon icon="mdi:github" fontSize={32} />
Login with GitHub Login with GitHub

View file

@ -6,7 +6,7 @@ import { signOut } from "next-auth/react";
export default function LogoutButton() { export default function LogoutButton() {
return ( return (
<li title="Logout"> <li title="Logout">
<button onClick={() => signOut()} aria-label="Log Out" className="pill button !p-0 aspect-square h-full" data-tooltip="Log Out"> <button onClick={() => signOut()} aria-label="Log Out" className="pill button p-0! aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} /> <Icon icon="ic:round-logout" fontSize={24} />
</button> </button>
</li> </li>

View file

@ -10,13 +10,17 @@ export default function GenderSelect() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null); const [selected, setSelected] = useState<MiiGender | null>(
(searchParams.get("gender") as MiiGender) ?? null
);
const handleClick = (gender: MiiGender) => { const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender; const filter = selected === gender ? null : gender;
setSelected(filter); setSelected(filter);
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) { if (filter) {
params.set("gender", filter); params.set("gender", filter);
} else { } else {
@ -35,10 +39,14 @@ export default function GenderSelect() {
aria-label="Filter for Male Miis" aria-label="Filter for Male Miis"
data-tooltip-span data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "MALE"
? "bg-blue-100 border-blue-400 shadow-md"
: "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip !bg-blue-400 !border-blue-400 before:!border-b-blue-400">Male</div> <div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">
Male
</div>
<Icon icon="foundation:male" className="text-blue-400" /> <Icon icon="foundation:male" className="text-blue-400" />
</button> </button>
@ -47,10 +55,14 @@ export default function GenderSelect() {
aria-label="Filter for Female Miis" aria-label="Filter for Female Miis"
data-tooltip-span data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" selected === "FEMALE"
? "bg-pink-100 border-pink-400 shadow-md"
: "bg-white border-gray-300 hover:border-gray-400"
}`} }`}
> >
<div className="tooltip !bg-pink-400 !border-pink-400 before:!border-b-pink-400">Female</div> <div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">
Female
</div>
<Icon icon="foundation:female" className="text-pink-400" /> <Icon icon="foundation:female" className="text-pink-400" />
</button> </button>
</div> </div>

View file

@ -2,11 +2,11 @@ import Link from "next/link";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client"; import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { z } from "zod";
import crypto from "crypto";
import seedrandom from "seedrandom"; import seedrandom from "seedrandom";
import { querySchema } from "@/lib/schemas"; 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";
@ -23,44 +23,26 @@ interface Props {
inLikesPage?: boolean; // Self-explanatory inLikesPage?: boolean; // Self-explanatory
} }
const searchSchema = z.object({ export default async function MiiList({
q: querySchema.optional(), searchParams,
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), userId,
tags: z inLikesPage,
.string() }: Props) {
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
const session = await auth(); const session = await auth();
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data; const {
q: query,
sort,
tags,
platform,
gender,
page = 1,
limit = 24,
seed,
} = parsed.data;
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -78,7 +60,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }), ...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching // Searching
...(query && { ...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }], OR: [
{ name: { contains: query, mode: "insensitive" } },
{ tags: { has: query } },
{ description: { contains: query, mode: "insensitive" } },
],
}), }),
// Tag filtering // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
@ -128,7 +114,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
if (sort === "random") { if (sort === "random") {
// Use seed for consistent random results // Use seed for consistent random results
const randomSeed = seed || Math.floor(Math.random() * 1_000_000_000); 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({
@ -174,7 +160,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
[totalCount, filteredCount, list] = await Promise.all([ [totalCount, filteredCount, list] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }), prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }), prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]); ]);
} }
@ -191,20 +183,28 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{totalCount == filteredCount ? ( {totalCount == filteredCount ? (
<> <>
<span className="text-2xl font-bold text-amber-900">{totalCount}</span> <span className="text-2xl font-bold text-amber-900">
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span> {totalCount}
</span>
<span className="text-lg text-amber-700">
{totalCount === 1 ? "Mii" : "Miis"}
</span>
</> </>
) : ( ) : (
<> <>
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span> <span className="text-2xl font-bold text-amber-900">
{filteredCount}
</span>
<span className="text-sm text-amber-700">of</span> <span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{totalCount}</span> <span className="text-lg font-semibold text-amber-800">
{totalCount}
</span>
<span className="text-lg text-amber-700">Miis</span> <span className="text-lg text-amber-700">Miis</span>
</> </>
)} )}
</div> </div>
<div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center"> <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu /> <FilterMenu />
<SortSelect /> <SortSelect />
</div> </div>
@ -220,37 +220,66 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
images={[ images={[
`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`, `/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`), ...Array.from(
{ length: mii.imageCount },
(_, index) => `/mii/${mii.id}/image?type=image${index}`
),
]} ]}
/> />
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}> <Link
href={`/mii/${mii.id}`}
className="font-bold text-2xl line-clamp-1"
title={mii.name}
>
{mii.name} {mii.name}
</Link> </Link>
<div id="tags" className="flex flex-wrap gap-1"> <div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag) => ( {mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs"> <Link
href={{ query: { tags: tag } }}
key={tag}
className="px-2 py-1 bg-orange-300 rounded-full text-xs"
>
{tag} {tag}
</Link> </Link>
))} ))}
</div> </div>
<div className="mt-auto grid grid-cols-2 items-center"> <div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate /> <LikeButton
likes={mii.likes}
miiId={mii.id}
isLiked={mii.isLiked}
isLoggedIn={session?.user != null}
abbreviate
/>
{!userId && ( {!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis"> <Link
href={`/profile/${mii.user?.id}`}
className="text-sm text-right overflow-hidden text-ellipsis"
>
@{mii.user?.username} @{mii.user?.username}
</Link> </Link>
)} )}
{userId && Number(session?.user.id) == userId && ( {userId && Number(session?.user.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400"> <div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit"> <Link
href={`/edit/${mii.id}`}
title="Edit Mii"
aria-label="Edit Mii"
data-tooltip="Edit"
>
<Icon icon="mdi:pencil" /> <Icon icon="mdi:pencil" />
</Link> </Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} /> <DeleteMiiButton
miiId={mii.id}
miiName={mii.name}
likes={mii.likes}
/>
</div> </div>
)} )}
</div> </div>

View file

@ -44,8 +44,8 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to First Page" aria-label="Go to First Page"
aria-disabled={page === 1} aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined} tabIndex={page === 1 ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${ className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400" page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`} }`}
> >
<Icon icon="stash:chevron-double-left" /> <Icon icon="stash:chevron-double-left" />
@ -57,7 +57,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to Previous Page" aria-label="Go to Previous Page"
aria-disabled={page === 1} aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined} tabIndex={page === 1 ? -1 : undefined}
className={`pill !bg-orange-100 !p-0.5 aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"}`} className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
> >
<Icon icon="stash:chevron-left" /> <Icon icon="stash:chevron-left" />
</Link> </Link>
@ -70,7 +70,7 @@ export default function Pagination({ lastPage }: Props) {
href={createPageUrl(number)} href={createPageUrl(number)}
aria-label={`Go to Page ${number}`} aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined} aria-current={number === page ? "page" : undefined}
className={`pill !p-0 w-8 h-8 text-center !rounded-md ${number == page ? "!bg-orange-400" : "!bg-orange-100 hover:!bg-orange-400"}`} className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
> >
{number} {number}
</Link> </Link>
@ -79,12 +79,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Next page */} {/* Next page */}
<Link <Link
href={page === lastPage ? "#" : createPageUrl(page + 1)} href={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page" aria-label="Go to Next Page"
aria-disabled={page === lastPage} aria-disabled={page >= lastPage}
tabIndex={page === lastPage ? -1 : undefined} tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${ className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400" page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`} }`}
> >
<Icon icon="stash:chevron-right" /> <Icon icon="stash:chevron-right" />
@ -92,12 +92,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Go to last page */} {/* Go to last page */}
<Link <Link
href={page === lastPage ? "#" : createPageUrl(lastPage)} href={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page" aria-label="Go to Last Page"
aria-disabled={page === lastPage} aria-disabled={page >= lastPage}
tabIndex={page === lastPage ? -1 : undefined} tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${ className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400" page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`} }`}
> >
<Icon icon="stash:chevron-double-right" /> <Icon icon="stash:chevron-double-right" />

View file

@ -21,7 +21,7 @@ export default function Skeleton() {
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */} {/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1"> <div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-[3/2]"></div> <div className="aspect-3/2"></div>
</div> </div>
{/* Content */} {/* Content */}

View file

@ -23,6 +23,7 @@ export default function SortSelect() {
if (!selectedItem) return; if (!selectedItem) return;
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem); params.set("sort", selectedItem);
if (selectedItem == "random") { if (selectedItem == "random") {
@ -38,7 +39,7 @@ export default function SortSelect() {
return ( return (
<div className="relative w-fit"> <div className="relative w-fit">
{/* Toggle button to open the dropdown */} {/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap"> <button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<span>Sort by </span> <span>Sort by </span>
{selectedItem || "Select a way to sort"} {selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" /> <Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -36,6 +36,8 @@ export default function TagFilter() {
if (urlTags === stateTags) return; if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) { if (tags.length > 0) {
params.set("tags", stateTags); params.set("tags", stateTags);
} else { } else {

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture"; import ProfilePicture from "./profile-picture";
import Description from "./description";
interface Props { interface Props {
userId?: number; userId?: number;
@ -34,7 +35,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
{/* User information */} {/* User information */}
<div className="flex flex-col w-full relative py-3"> <div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-3xl font-extrabold break-words">{user.name}</h1> <h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
{isAdmin && ( {isAdmin && (
<div data-tooltip="Admin" className="text-orange-400"> <div data-tooltip="Admin" className="text-orange-400">
<Icon icon="mdi:shield-moon" className="text-2xl" /> <Icon icon="mdi:shield-moon" className="text-2xl" />
@ -46,9 +47,9 @@ export default async function ProfileInformation({ userId, page }: Props) {
</div> </div>
)} )}
</div> </div>
<h2 className="text-black/60 text-sm font-semibold break-words">@{user?.username}</h2> <h2 className="text-black/60 text-sm font-semibold wrap-break-word">@{user?.username}</h2>
<div className="mt-auto text-sm flex gap-8"> <div className="mt-3 text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}> <h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "} <span className="font-medium">Created:</span>{" "}
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })} {user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
@ -57,6 +58,8 @@ export default async function ProfileInformation({ userId, page }: Props) {
Liked <span className="font-bold">{likedMiis}</span> Miis Liked <span className="font-bold">{likedMiis}</span> Miis
</h4> </h4>
</div> </div>
{user.description && <Description text={user.description} className="max-h-32!" />}
</div> </div>
</div> </div>

View file

@ -10,7 +10,7 @@ export default async function ProfileOverview() {
<Link <Link
href={`/profile/${session?.user.id}`} href={`/profile/${session?.user.id}`}
aria-label="Go to profile" aria-label="Go to profile"
className="pill button !gap-2 !p-0 h-full max-w-64" className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile" data-tooltip="Your Profile"
> >
<Image <Image

View file

@ -7,5 +7,5 @@ export default function ProfilePicture(props: Partial<ImageProps>) {
const { src, ...rest } = props; const { src, ...rest } = props;
const [imgSrc, setImgSrc] = useState(src); const [imgSrc, setImgSrc] = useState(src);
return <Image {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} width={128} height={128} onError={() => setImgSrc("/guest.webp")} />; return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} onError={() => setImgSrc("/guest.webp")} />;
} }

View file

@ -42,14 +42,14 @@ export default function DeleteAccount() {
<button <button
name="deletion" name="deletion"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="pill button w-fit h-min ml-auto !bg-red-400 !border-red-500 hover:!bg-red-500" className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"
> >
Delete Account Delete Account
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--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 ${
@ -79,7 +79,7 @@ export default function DeleteAccount() {
<button onClick={close} className="pill button"> <button onClick={close} className="pill button">
Cancel Cancel
</button> </button>
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" /> <SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div> </div>
</div> </div>
</div>, </div>,

View file

@ -9,18 +9,48 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture"; import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account"; import DeleteAccount from "./delete-account";
import z from "zod";
export default function ProfileSettings() { interface Props {
currentDescription: string | null | undefined;
}
export default function ProfileSettings({ currentDescription }: Props) {
const router = useRouter(); const router = useRouter();
const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined); const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined); const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days"); const usernameDate = dayjs().add(90, "days");
const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description);
if (!parsed.success) {
setDescriptionChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/about-me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
});
if (!response.ok) {
const { error } = await response.json();
setDescriptionChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitDisplayNameChange = async (close: () => void) => { const handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName); const parsed = displayNameSchema.safeParse(displayName);
if (!parsed.success) { if (!parsed.success) {
@ -76,25 +106,54 @@ export default function ProfileSettings() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Account Info</span> <span>Account Info</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Profile Picture */} {/* Profile Picture */}
<ProfilePictureSettings /> <ProfilePictureSettings />
{/* Description */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">About Me</label>
<p className="text-sm text-zinc-500">Write about yourself on your profile</p>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<div className="flex-1">
<textarea
rows={5}
maxLength={256}
placeholder="(optional) Type about yourself..."
className="pill input rounded-xl! resize-none text-sm w-full"
value={description || ""}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-xs text-zinc-400 mt-1 text-right">{(description || "").length}/256</p>
</div>
<SubmitDialogButton
title="Confirm About Me Change"
description="Are you sure? You can change it again later."
error={descriptionChangeError}
onSubmit={handleSubmitDescriptionChange}
/>
</div>
</div>
{/* Change Name */} {/* Change Name */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div> <div className="col-span-3">
<label className="font-semibold">Change Display Name</label> <label className="font-semibold">Change Display Name</label>
<p className="text-sm text-zinc-500">This is a display name shown on your profile feel free to change it anytime</p> <p className="text-sm text-zinc-500">This is a display name shown on your profile feel free to change it anytime</p>
</div> </div>
<div className="flex justify-end gap-1 h-min"> <div className="flex justify-end gap-1 h-min col-span-2">
<input <input
type="text" type="text"
className="pill input w-full max-w-64" className="pill input flex-1"
placeholder="Type here..." placeholder="Type here..."
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@ -114,14 +173,14 @@ export default function ProfileSettings() {
</div> </div>
{/* Change Username */} {/* Change Username */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div> <div className="col-span-3">
<label className="font-semibold">Change Username</label> <label className="font-semibold">Change Username</label>
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p> <p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
</div> </div>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1 col-span-2">
<div className="relative w-full max-w-64"> <div className="relative flex-1">
<input <input
type="text" type="text"
className="pill input w-full indent-4" className="pill input w-full indent-4"
@ -152,9 +211,9 @@ export default function ProfileSettings() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Danger Zone</span> <span>Danger Zone</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Delete Account */} {/* Delete Account */}

View file

@ -43,13 +43,13 @@ export default function ProfilePictureSettings() {
}, []); }, []);
return ( return (
<div className="grid grid-cols-2"> <div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div> <div className="col-span-3">
<label className="font-semibold">Profile Picture</label> <label className="font-semibold">Profile Picture</label>
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p> <p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col col-span-2">
<div className="flex justify-end"> <div className="flex justify-end">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}> <Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-xs"> <p className="text-center text-xs">
@ -74,7 +74,7 @@ export default function ProfilePictureSettings() {
data-tooltip="Delete Picture" data-tooltip="Delete Picture"
aria-label="Delete Picture" aria-label="Delete Picture"
onClick={() => setNewPicture(undefined)} onClick={() => setNewPicture(undefined)}
className="pill button aspect-square !p-1 text-2xl !bg-red-400 !border-red-500" className="pill button aspect-square p-1! text-2xl bg-red-400! border-red-500!"
> >
<Icon icon="mdi:trash-outline" /> <Icon icon="mdi:trash-outline" />
</button> </button>

View file

@ -37,13 +37,13 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
return ( return (
<> <>
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 !p-1 text-2xl"> <button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
<Icon icon="material-symbols:check-rounded" /> <Icon icon="material-symbols:check-rounded" />
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <div className="fixed inset-0 w-full 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 ${

View file

@ -5,7 +5,7 @@ import { Icon } from "@iconify/react";
export default function RandomLink() { export default function RandomLink() {
return ( return (
<Link href={"/random"} aria-label="Go to Random Link" className="pill button !p-0 h-full aspect-square" data-tooltip="Go to a Random Mii"> <Link href={"/random"} aria-label="Go to Random Link" className="pill button p-0! h-full aspect-square" data-tooltip="Go to a Random Mii">
<Icon icon="mdi:dice-3" fontSize={28} /> <Icon icon="mdi:dice-3" fontSize={28} />
</Link> </Link>
); );

View file

@ -67,7 +67,7 @@ export default function ReportMiiForm({ mii, likes }: Props) {
rows={3} rows={3}
maxLength={256} maxLength={256}
placeholder="Type notes here for the report..." placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2" className="pill input rounded-xl! resize-none col-span-2"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />

View file

@ -40,7 +40,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
type="button" type="button"
{...getToggleButtonProps()} {...getToggleButtonProps()}
aria-label="Report reason dropdown" aria-label="Report reason dropdown"
className="pill input w-full gap-1 !justify-between text-nowrap" className="pill input w-full gap-1 justify-between! text-nowrap"
> >
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>} {selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" /> <Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -65,7 +65,7 @@ export default function ReportUserForm({ user }: Props) {
rows={3} rows={3}
maxLength={256} maxLength={256}
placeholder="Type notes here for the report..." placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2" className="pill input rounded-xl! resize-none col-span-2"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />

View file

@ -67,7 +67,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--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 ${
@ -92,7 +92,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */} {/* Copy button */}
<button <button
className="!absolute top-2.5 right-2.5 cursor-pointer" className="absolute! top-2.5 right-2.5 cursor-pointer"
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
onClick={handleCopyUrl} onClick={handleCopyUrl}
> >
@ -118,9 +118,9 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>or</span> <span>or</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg"> <div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
@ -139,7 +139,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Save button */} {/* Save button */}
<a <a
href={`/mii/${miiId}/image?type=metadata`} href={`/mii/${miiId}/image?type=metadata`}
className="pill button !p-0 aspect-square cursor-pointer text-xl" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"
download={"hello.png"} download={"hello.png"}
@ -149,7 +149,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */} {/* Copy button */}
<button <button
className="pill button !p-0 aspect-square cursor-pointer" className="pill button p-0! aspect-square size-11 cursor-pointer"
aria-label="Copy Image" aria-label="Copy Image"
data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"} data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"}
onClick={handleCopyImage} onClick={handleCopyImage}

View file

@ -106,7 +106,7 @@ export default function EditForm({ mii, likes }: Props) {
return ( return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel <Carousel
images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]}
/> />
@ -139,9 +139,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Info</span> <span>Info</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="w-full grid grid-cols-3 items-center"> <div className="w-full grid grid-cols-3 items-center">
@ -164,7 +164,7 @@ export default function EditForm({ mii, likes }: Props) {
<label htmlFor="tags" className="font-semibold"> <label htmlFor="tags" className="font-semibold">
Tags Tags
</label> </label>
<TagSelector tags={tags} setTags={setTags} /> <TagSelector tags={tags} setTags={setTags} showTagLimit />
</div> </div>
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">
@ -172,10 +172,10 @@ export default function EditForm({ mii, likes }: Props) {
Description Description
</label> </label>
<textarea <textarea
rows={3} rows={5}
maxLength={256} maxLength={256}
placeholder="(optional) Type a description..." placeholder="(optional) Type a description..."
className="pill input !rounded-xl resize-none col-span-2" className="pill input rounded-xl! resize-none col-span-2 text-sm"
value={description ?? ""} value={description ?? ""}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
@ -183,9 +183,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Custom images</span> <span>Custom images</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="max-w-md w-full self-center"> <div className="max-w-md w-full self-center">

View file

@ -43,13 +43,13 @@ export default function ImageList({ files, setFiles }: Props) {
alt={file.name} alt={file.name}
width={96} width={96}
height={96} height={96}
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400" className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/> />
<div className="flex flex-col justify-center w-full min-w-0"> <div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span> <span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
<button <button
onClick={() => handleDelete(index)} onClick={() => handleDelete(index)}
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400" className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
> >
Delete Delete
</button> </button>

View file

@ -27,17 +27,6 @@ import SubmitButton from "../submit-button";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
export default function SubmitForm() { export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [files, setFiles] = useState<FileWithPath[]>([]); const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback( const handleDrop = useCallback(
@ -48,6 +37,20 @@ export default function SubmitForm() {
[files.length] [files.length]
); );
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE");
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate before sending request // Validate before sending request
const nameValidation = nameSchema.safeParse(name); const nameValidation = nameSchema.safeParse(name);
@ -75,7 +78,17 @@ export default function SubmitForm() {
if (platform === "SWITCH") { if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!); const response = await fetch(miiPortraitUri!);
if (!response.ok) {
setError("Failed to check Mii portrait. Did you upload one?");
return;
}
const blob = await response.blob(); const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
setError("Invalid image file returned");
return;
}
formData.append("gender", gender); formData.append("gender", gender);
formData.append("miiPortraitImage", blob); formData.append("miiPortraitImage", blob);
@ -139,10 +152,8 @@ export default function SubmitForm() {
return ( return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel <Carousel images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
/>
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}> <h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -172,9 +183,9 @@ export default function SubmitForm() {
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Info</span> <span>Info</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
{/* Platform select */} {/* Platform select */}
@ -184,6 +195,7 @@ export default function SubmitForm() {
</label> </label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10"> <div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
{/* Animated indicator */} {/* Animated indicator */}
{/* TODO: maybe change width as part of animation? */}
<div <div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${ className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full" platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
@ -194,8 +206,8 @@ export default function SubmitForm() {
<button <button
type="button" type="button"
onClick={() => setPlatform("SWITCH")} onClick={() => setPlatform("SWITCH")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${ className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "!text-black" platform === "SWITCH" && "text-slate-800!"
}`} }`}
> >
<Icon icon="cib:nintendo-switch" className="text-2xl" /> <Icon icon="cib:nintendo-switch" className="text-2xl" />
@ -206,8 +218,8 @@ export default function SubmitForm() {
<button <button
type="button" type="button"
onClick={() => setPlatform("THREE_DS")} onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${ className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "!text-black" platform === "THREE_DS" && "text-slate-800!"
}`} }`}
> >
<Icon icon="cib:nintendo-3ds" className="text-2xl" /> <Icon icon="cib:nintendo-3ds" className="text-2xl" />
@ -237,7 +249,7 @@ export default function SubmitForm() {
<label htmlFor="tags" className="font-semibold"> <label htmlFor="tags" className="font-semibold">
Tags Tags
</label> </label>
<TagSelector tags={tags} setTags={setTags} /> <TagSelector tags={tags} setTags={setTags} showTagLimit />
</div> </div>
{/* Description */} {/* Description */}
@ -247,10 +259,10 @@ export default function SubmitForm() {
</label> </label>
<textarea <textarea
name="description" name="description"
rows={3} rows={5}
maxLength={256} maxLength={256}
placeholder="(optional) Type a description..." placeholder="(optional) Type a description..."
className="pill input !rounded-xl resize-none col-span-2" className="pill input rounded-xl! resize-none col-span-2 text-sm"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
@ -292,9 +304,9 @@ export default function SubmitForm() {
<> <>
{/* Separator */} {/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Mii Portrait</span> <span>Mii Portrait</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@ -305,16 +317,21 @@ export default function SubmitForm() {
{/* QR code selector */} {/* QR code selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>QR Code</span> <span>QR Code</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span> <span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
{platform === "THREE_DS" ? ( {platform === "THREE_DS" ? (
<> <>
<ThreeDsSubmitTutorialButton /> <ThreeDsSubmitTutorialButton />
@ -328,12 +345,12 @@ export default function SubmitForm() {
{/* Custom images selector */} {/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Custom images</span> <span>Custom images</span>
<hr className="flex-grow border-zinc-300" /> <hr className="grow border-zinc-300" />
</div> </div>
<div className="max-w-md w-full self-center"> <div className="max-w-md w-full self-center flex flex-col items-center">
<Dropzone onDrop={handleDrop}> <Dropzone onDrop={handleDrop}>
<p className="text-center text-sm"> <p className="text-center text-sm">
Drag and drop your images here Drag and drop your images here
@ -341,6 +358,8 @@ export default function SubmitForm() {
or click to open or click to open
</p> </p>
</Dropzone> </Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div> </div>
<ImageList files={files} setFiles={setFiles} /> <ImageList files={files} setFiles={setFiles} />

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { FileWithPath } from "react-dropzone"; import { FileWithPath } from "react-dropzone";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
@ -9,6 +9,8 @@ interface Props {
} }
export default function PortraitUpload({ setImage }: Props) { export default function PortraitUpload({ setImage }: Props) {
const [hasImage, setHasImage] = useState(false);
const handleDrop = useCallback( const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => { (acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0]; const file = acceptedFiles[0];
@ -16,6 +18,7 @@ export default function PortraitUpload({ setImage }: Props) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
setImage(event.target!.result as string); setImage(event.target!.result as string);
setHasImage(true);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
@ -26,9 +29,15 @@ export default function PortraitUpload({ setImage }: Props) {
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}> <Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm"> <p className="text-center text-sm">
Drag and drop your Mii&apos;s portrait here {!hasImage ? (
<br /> <>
or click to open Drag and drop your Mii&apos;s portrait here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p> </p>
</Dropzone> </Dropzone>
</div> </div>

View file

@ -9,14 +9,17 @@ import QrFinder from "./qr-finder";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
interface Props { interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>; setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
} }
export default function QrScanner({ setQrBytesRaw }: Props) { export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); 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 [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null); const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
@ -39,7 +42,8 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
selectedItem, selectedItem,
} = useSelect({ } = useSelect({
items: cameraItems, items: cameraItems,
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null, selectedItem:
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
onSelectedItemChange: ({ selectedItem }) => { onSelectedItemChange: ({ selectedItem }) => {
setSelectedDeviceId(selectedItem?.value ?? null); setSelectedDeviceId(selectedItem?.value ?? null);
}, },
@ -65,7 +69,12 @@ export default function QrScanner({ 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(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); const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code) return; if (!code) return;
@ -126,112 +135,128 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
}; };
}, [isOpen, permissionGranted, scanQRCode]); }, [isOpen, permissionGranted, scanQRCode]);
if (!isOpen) return null;
return ( return (
<> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<button type="button" aria-label="Use your camera" onClick={() => setIsOpen(true)} className="pill button gap-2"> <div
<Icon icon="mdi:camera" fontSize={20} /> onClick={close}
Use your camera className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
</button> isVisible ? "opacity-100" : "opacity-0"
}`}
/>
{isOpen && ( <div
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
<div isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button
type="button"
aria-label="Close"
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
> >
<div className="flex justify-between items-center mb-2"> <Icon icon="material-symbols:close-rounded" />
<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"> </div>
<Icon icon="material-symbols:close-rounded" />
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button> </button>
</div>
{devices.length > 1 && ( {/* Dropdown menu */}
<div className="mb-4 flex flex-col gap-1"> <ul
<label className="text-sm font-semibold">Camera:</label> {...getMenuProps({}, { suppressRefError: true })}
<div className="relative w-full"> className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
{/* Toggle button to open the dropdown */} isDropdownOpen ? "block" : "hidden"
<button }`}
type="button" >
aria-label="Select camera dropdown" {isDropdownOpen &&
{...getToggleButtonProps({}, { suppressRefError: true })} cameraItems.map((item, index) => (
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm" <li
> key={item.value}
{selectedItem?.label || "Select a camera"} {...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${
<Icon icon="tabler:chevron-down" className="ml-2 size-5" /> highlightedIndex === index ? "bg-black/15" : ""
</button> }`}
>
{/* Dropdown menu */} {item.label}
<ul </li>
{...getMenuProps({}, { suppressRefError: true })} ))}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${ </ul>
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div> </div>
</div> </div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">
Camera access denied
</p>
<p className="text-gray-600">
Please allow camera access in your browser settings to scan QR
codes
</p>
<button
type="button"
onClick={requestPermission}
className="pill button text-xs mt-2 py-0.5! px-2!"
>
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId
? { exact: selectedDeviceId }
: undefined,
...(selectedDeviceId
? {}
: { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices =
await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter(
(d) => d.kind === "videoinput"
);
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div> </div>
)}
</> <div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div>
</div>
</div>
); );
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useRef } from "react"; import { useCallback, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone"; import { FileWithPath } from "react-dropzone";
import jsQR from "jsqr"; import jsQR from "jsqr";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
@ -10,6 +10,7 @@ interface Props {
} }
export default function QrUpload({ setQrBytesRaw }: Props) { export default function QrUpload({ setQrBytesRaw }: Props) {
const [hasImage, setHasImage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const handleDrop = useCallback( const handleDrop = useCallback(
@ -36,6 +37,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
if (!code) return; if (!code) return;
setQrBytesRaw(code.binaryData!); setQrBytesRaw(code.binaryData!);
setHasImage(true);
}; };
image.src = event.target!.result as string; image.src = event.target!.result as string;
}; };
@ -48,9 +50,15 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}> <Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm"> <p className="text-center text-sm">
Drag and drop your QR code image here {!hasImage ? (
<br /> <>
or click to open Drag and drop your QR code image here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p> </p>
</Dropzone> </Dropzone>

View file

@ -1,29 +1,46 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useRef } from "react";
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface Props { interface Props {
tags: string[]; tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>; setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
} }
const tagRegex = /^[a-z0-9-_]*$/; 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 }: Props) { export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
const [inputValue, setInputValue] = useState<string>(""); const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] => 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 filteredItems = getFilteredItems();
const isMaxItemsSelected = tags.length >= 8; const isMaxItemsSelected = tags.length >= 8;
const hasSelectedItems = tags.length > 0; const hasSelectedItems = tags.length > 0;
const addTag = (tag: string) => { const addTag = (tag: string) => {
if (!tags.includes(tag) && tags.length < 8) { if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
setTags([...tags, tag]); setTags([...tags, tag]);
} }
}; };
@ -32,7 +49,15 @@ export default function TagSelector({ tags, setTags }: Props) {
setTags(tags.filter((t) => t !== tag)); setTags(tags.filter((t) => t !== tag));
}; };
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({ const {
isOpen,
openMenu,
getToggleButtonProps,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
} = useCombobox<string>({
inputValue, inputValue,
items: filteredItems, items: filteredItems,
onInputValueChange: ({ inputValue }) => { onInputValueChange: ({ inputValue }) => {
@ -61,85 +86,129 @@ export default function TagSelector({ tags, setTags }: Props) {
} }
}; };
const handleContainerClick = () => {
if (!isMaxItemsSelected) {
inputRef.current?.focus();
openMenu();
}
};
return ( return (
<div <div className="col-span-2 relative">
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${ <div
tags.length > 0 ? "!py-1.5" : "" className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
}`} tags.length > 0 ? "py-1.5! px-2!" : ""
> }`}
{/* Tags */} onClick={handleContainerClick}
<div className="flex flex-wrap gap-1.5 w-full"> >
{tags.map((tag) => ( {/* Tags */}
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"> <div className="flex flex-wrap gap-1.5 w-full">
{tag} {tags.map((tag) => (
<span
key={tag}
className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"
>
{tag}
<button
type="button"
aria-label="Delete Tag"
className="text-slate-800 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
>
<Icon icon="mdi:close" className="text-xs" />
</button>
</span>
))}
{/* Input */}
<input
{...getInputProps({
ref: inputRef,
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
maxLength: 20,
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{hasSelectedItems && (
<button <button
type="button" type="button"
aria-label="Delete Tag" aria-label="Remove All Tags"
className="text-black cursor-pointer" className="text-black cursor-pointer"
onClick={(e) => { onClick={() => setTags([])}
e.stopPropagation();
removeTag(tag);
}}
> >
<Icon icon="mdi:close" className="text-xs" /> <Icon icon="mdi:close" />
</button> </button>
</span> )}
))}
{/* Input */} <button
<input type="button"
{...getInputProps({ aria-label="Toggle Tag Dropdown"
onKeyDown: handleKeyDown, {...getToggleButtonProps()}
disabled: isMaxItemsSelected, disabled={isMaxItemsSelected}
placeholder: tags.length > 0 ? "" : "Type or select a tag...", className="text-black cursor-pointer text-xl disabled:text-black/35"
className: "w-full flex-1 outline-none placeholder:text-black/40", >
})} <Icon icon="mdi:chevron-down" />
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1">
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
</button> </button>
)} </div>
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl"> {/* Dropdown menu */}
<Icon icon="mdi:chevron-down" /> {!isMaxItemsSelected && (
</button> <ul
</div> {...getMenuProps()}
onClick={(e) => e.stopPropagation()}
{/* Dropdown menu */} className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
{!isMaxItemsSelected && ( isOpen ? "block" : "hidden"
<ul }`}
{...getMenuProps()} >
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${ {filteredItems.map((item, index) => (
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
filteredItems.map((item, index) => (
<li <li
key={item} key={item}
{...getItemProps({ item, index })} {...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} {item}
</li> </li>
))} ))}
{isOpen && inputValue && !filteredItems.includes(inputValue) && ( {inputValue && !filteredItems.includes(inputValue) && (
<li <li
className="px-4 py-1 cursor-pointer text-sm bg-black/15" className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => { onClick={() => {
addTag(inputValue); addTag(inputValue);
setInputValue(""); setInputValue("");
}} }}
> >
Add &quot;{inputValue}&quot; Add &quot;{inputValue}&quot;
</li> </li>
)}
</ul>
)}
</div>
{/* Tag limit message */}
{showTagLimit && (
<div className="mt-1.5 text-xs min-h-4">
{isMaxItemsSelected ? (
<span className="text-red-400 font-medium">
Maximum of 8 tags reached. Remove a tag to add more.
</span>
) : (
<span className="text-black/60">{tags.length}/8 tags</span>
)} )}
</ul> </div>
)} )}
</div> </div>
); );

View file

@ -1,102 +1,59 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Tutorial from ".";
import TutorialPage from "./page";
export default function ThreeDsScanTutorialButton() { export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return ( return (
<> <>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer"> <button
aria-label="Tutorial"
type="button"
onClick={() => setIsOpen(true)}
className="text-3xl cursor-pointer"
>
<Icon icon="fa:question-circle" /> <Icon icon="fa:question-circle" />
<span>Tutorial</span> <span>Tutorial</span>
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <Tutorial
<div tutorials={[
onClick={close} {
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ title: "Adding Mii",
isVisible ? "opacity-100" : "opacity-0" steps: [
}`} {
/> text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
<div },
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${ {
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" text: "2. Go into 'QR Code'",
}`} imageSrc: "/tutorial/3ds/adding-mii/step2.png",
> },
<div className="flex justify-between items-center mb-2 p-6 pb-0"> {
<h2 className="text-xl font-bold">Tutorial</h2> text: "3. Press 'Scan QR Code'",
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> imageSrc: "/tutorial/3ds/adding-mii/step3.png",
<Icon icon="material-symbols:close-rounded" /> },
</button> {
</div> text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
<div className="flex flex-col min-h-0 h-full"> },
<div className="overflow-hidden h-full" ref={emblaRef}> {
<div className="flex h-full"> text: "5. Scan with your 3DS",
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/3ds/step1.png" /> imageSrc: "/tutorial/3ds/adding-mii/step5.png",
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/3ds/adding-mii/step2.png" /> },
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/3ds/adding-mii/step3.png" /> { type: "finish" },
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/3ds/adding-mii/step4.png" /> ],
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/3ds/adding-mii/step5.png" /> },
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} /> ]}
</div> isOpen={isOpen}
</div> setIsOpen={setIsOpen}
/>,
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body document.body
)} )}
</> </>

View file

@ -1,129 +1,99 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react"; import Tutorial from ".";
import { Icon } from "@iconify/react";
import TutorialPage from "./page"; export default function SubmitTutorialButton() {
import StartingPage from "./starting-page";
export default function ThreeDsSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return ( return (
<> <>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"> <button
type="button"
onClick={() => setIsOpen(true)}
className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"
>
How to? How to?
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <Tutorial
<div tutorials={[
onClick={close} {
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ title: "Allow Copying",
isVisible ? "opacity-100" : "opacity-0" thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
}`} hint: "Suggested!",
/> steps: [
{ type: "start" },
<div {
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${ text: "1. Enter the town hall",
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" imageSrc: "/tutorial/3ds/step1.png",
}`} },
> {
<div className="flex justify-between items-center mb-2 p-6 pb-0"> text: "2. Go into 'Mii List'",
<h2 className="text-xl font-bold">Tutorial</h2> imageSrc: "/tutorial/3ds/allow-copying/step2.png",
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> },
<Icon icon="material-symbols:close-rounded" /> {
</button> text: "3. Select and edit the Mii you wish to submit",
</div> imageSrc: "/tutorial/3ds/allow-copying/step3.png",
},
<div className="flex flex-col min-h-0 h-full"> {
<div className="overflow-hidden h-full" ref={emblaRef}> text: "4. Click 'Other Settings' in the information screen",
<div className="flex h-full"> imageSrc: "/tutorial/3ds/allow-copying/step4.png",
<StartingPage isSwitch emblaApi={emblaApi} /> },
{
{/* Allow Copying */} text: "5. Click on 'Don't Allow' under the 'Copying' text",
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" /> imageSrc: "/tutorial/3ds/allow-copying/step5.png",
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/allow-copying/step2.png" /> },
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/allow-copying/step3.png" /> {
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/allow-copying/step4.png" /> text: "6. Press 'Allow'",
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/allow-copying/step5.png" /> imageSrc: "/tutorial/3ds/allow-copying/step6.png",
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/allow-copying/step6.png" /> },
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/allow-copying/step7.png" /> {
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} /> text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
<StartingPage emblaApi={emblaApi} /> },
{ type: "finish" },
{/* Create QR Code */} ],
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" /> },
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/create-qr-code/step2.png" /> {
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/create-qr-code/step3.png" /> title: "Create QR Code",
<TutorialPage text="4. Select and press 'OK' on the Mii you wish to submit" imageSrc="/tutorial/create-qr-code/step4.png" /> thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
<TutorialPage steps: [
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission." { type: "start" },
imageSrc="/tutorial/create-qr-code/step5.png" {
/> text: "1. Enter the town hall",
<TutorialPage imageSrc: "/tutorial/3ds/step1.png",
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)." },
imageSrc="/tutorial/create-qr-code/step6.png" {
/> text: "2. Go into 'QR Code'",
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} /> imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
</div> },
</div> {
text: "3. Press 'Create QR Code'",
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}> imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
<button },
onClick={() => emblaApi?.scrollPrev()} {
disabled={isStartingPage} text: "4. Select and press 'OK' on the Mii you wish to submit",
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`} imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
aria-label="Scroll Carousel Left" },
> {
<Icon icon="tabler:chevron-left" /> text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
</button> imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
},
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span> {
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
<button imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
onClick={() => emblaApi?.scrollNext()} },
disabled={isStartingPage} { type: "finish" },
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`} ],
aria-label="Scroll Carousel Right" },
> ]}
<Icon icon="tabler:chevron-right" /> isOpen={isOpen}
</button> setIsOpen={setIsOpen}
</div> />,
</div>
</div>
</div>,
document.body document.body
)} )}
</> </>

View file

@ -0,0 +1,215 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide {
// step is never used, undefined is assumed as a step
type?: "start" | "step" | "finish";
text?: string;
imageSrc?: string;
}
interface Tutorial {
title: string;
thumbnail?: string;
hint?: string;
steps: Slide[];
}
interface Props {
tutorials: Tutorial[];
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map
const slides: Array<Slide & { tutorialTitle: string }> = [];
const startSlides: Record<string, number> = {};
tutorials.forEach((tutorial) => {
tutorial.steps.forEach((slide) => {
if (slide.type === "start") {
startSlides[tutorial.title] = slides.length;
}
slides.push({ ...slide, tutorialTitle: tutorial.title });
});
});
const currentSlide = slides[selectedIndex];
const isStartingPage = currentSlide?.type === "start";
useEffect(() => {
if (currentSlide.type !== "finish") return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [currentSlide]);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
const goToTutorial = (tutorialTitle: string) => {
if (!emblaApi) return;
const index = startSlides[tutorialTitle];
// Jump to next starting slide then transition to actual tutorial
emblaApi.scrollTo(index, true);
emblaApi.scrollTo(index + 1);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-120 transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{slides.map((slide, index) => (
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
{slide.type === "start" ? (
<>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
{tutorials.map((tutorial, tutorialIndex) => (
<button
key={tutorialIndex}
onClick={() => goToTutorial(tutorial.title)}
aria-label={tutorial.title + " tutorial"}
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={tutorial.thumbnail!}
alt="tutorial thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">{tutorial.title}</p>
{/* Set opacity to 0 to keep height the same with other tutorials */}
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
</button>
))}
</div>
</>
) : slide.type === "finish" ? (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
) : (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
<Image
src={slide.imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
loading="eager"
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
)}
</div>
))}
</div>
</div>
{/* Arrows */}
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
{/* Only show tutorial name on step slides */}
<span
className={`text-sm transition-opacity duration-300 ${
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
}`}
>
{currentSlide?.tutorialTitle}
</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,59 +0,0 @@
"use client";
import Image from "next/image";
import { Icon } from "@iconify/react";
import { useEffect } from "react";
import confetti from "canvas-confetti";
interface Props {
text?: string;
imageSrc?: string;
carouselIndex?: number;
finishIndex?: number;
}
export default function TutorialPage({ text, imageSrc, carouselIndex, finishIndex }: Props) {
useEffect(() => {
if (carouselIndex !== finishIndex || !carouselIndex || !finishIndex) return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [carouselIndex, finishIndex]);
return (
<div className="flex-shrink-0 flex flex-col w-full px-6">
{!finishIndex ? (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{text}</p>
<Image
src={imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
) : (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
)}
</div>
);
}

View file

@ -1,62 +0,0 @@
import Image from "next/image";
import { UseEmblaCarouselType } from "embla-carousel-react";
interface Props {
emblaApi: UseEmblaCarouselType[1] | undefined;
isSwitch?: boolean;
}
export default function StartingPage({ emblaApi, isSwitch }: Props) {
const goToTutorial = (index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index - 1, true);
emblaApi.scrollTo(index);
};
return (
<div className="flex-shrink-0 flex flex-col w-full px-6 py-6">
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
<button
onClick={() => goToTutorial(1)}
aria-label="Allow Copying Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
alt="Allow Copying thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Allow Copying</p>
<p className="text-[0.65rem] text-zinc-400">Suggested!</p>
</button>
<button
onClick={() => goToTutorial(10)}
aria-label="Create QR Code Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
alt="Creating QR code thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Create QR Code</p>
{/* Add placeholder to keep height the same */}
<p className="text-[0.65rem] opacity-0">placeholder</p>
</button>
</div>
</div>
);
}

View file

@ -1,102 +1,59 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Tutorial from ".";
import TutorialPage from "./page"; export default function ThreeDsScanTutorialButton() {
export default function SwitchScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return ( return (
<> <>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer"> <button
aria-label="Tutorial"
type="button"
onClick={() => setIsOpen(true)}
className="text-3xl cursor-pointer"
>
<Icon icon="fa:question-circle" /> <Icon icon="fa:question-circle" />
<span>Tutorial</span> <span>Tutorial</span>
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <Tutorial
<div tutorials={[
onClick={close} {
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ title: "Adding Mii",
isVisible ? "opacity-100" : "opacity-0" steps: [
}`} {
/> text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
<div },
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${ {
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" text: "2. Go into 'QR Code'",
}`} imageSrc: "/tutorial/switch/adding-mii/step2.png",
> },
<div className="flex justify-between items-center mb-2 p-6 pb-0"> {
<h2 className="text-xl font-bold">Tutorial</h2> text: "3. Press 'Scan QR Code'",
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> imageSrc: "/tutorial/switch/adding-mii/step3.png",
<Icon icon="material-symbols:close-rounded" /> },
</button> {
</div> text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/switch/adding-mii/step4.png",
<div className="flex flex-col min-h-0 h-full"> },
<div className="overflow-hidden h-full" ref={emblaRef}> {
<div className="flex h-full"> text: "5. Scan with your 3DS",
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" /> imageSrc: "/tutorial/switch/adding-mii/step5.png",
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/adding-mii/step2.png" /> },
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/switch/adding-mii/step3.png" /> { type: "finish" },
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/switch/adding-mii/step4.png" /> ],
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/switch/adding-mii/step5.png" /> },
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} /> ]}
</div> isOpen={isOpen}
</div> setIsOpen={setIsOpen}
/>,
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body document.body
)} )}
</> </>

View file

@ -1,132 +1,99 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react"; import Tutorial from ".";
import { Icon } from "@iconify/react";
import TutorialPage from "./page"; export default function SubmitTutorialButton() {
import StartingPage from "./starting-page";
export default function SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return ( return (
<> <>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"> <button
type="button"
onClick={() => setIsOpen(true)}
className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"
>
How to? How to?
</button> </button>
{isOpen && {isOpen &&
createPortal( createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40"> <Tutorial
<div tutorials={[
onClick={close} {
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ title: "Allow Copying",
isVisible ? "opacity-100" : "opacity-0" thumbnail: "/tutorial/switch/allow-copying/thumbnail.png",
}`} hint: "Suggested!",
/> steps: [
{ type: "start" },
<div {
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${ text: "1. Enter the town hall",
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" imageSrc: "/tutorial/switch/step1.png",
}`} },
> {
<div className="flex justify-between items-center mb-2 p-6 pb-0"> text: "2. Go into 'Mii List'",
<h2 className="text-xl font-bold">Tutorial</h2> imageSrc: "/tutorial/switch/allow-copying/step2.png",
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> },
<Icon icon="material-symbols:close-rounded" /> {
</button> text: "3. Select and edit the Mii you wish to submit",
</div> imageSrc: "/tutorial/switch/allow-copying/step3.png",
},
<div className="flex flex-col min-h-0 h-full"> {
<div className="overflow-hidden h-full" ref={emblaRef}> text: "4. Click 'Other Settings' in the information screen",
<div className="flex h-full"> imageSrc: "/tutorial/switch/allow-copying/step4.png",
<StartingPage isSwitch emblaApi={emblaApi} /> },
{
{/* Allow Copying */} text: "5. Click on 'Don't Allow' under the 'Copying' text",
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" /> imageSrc: "/tutorial/switch/allow-copying/step5.png",
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/switch/allow-copying/step2.png" /> },
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/switch/allow-copying/step3.png" /> {
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/switch/allow-copying/step4.png" /> text: "6. Press 'Allow'",
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/switch/allow-copying/step5.png" /> imageSrc: "/tutorial/switch/allow-copying/step6.png",
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/switch/allow-copying/step6.png" /> },
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/switch/allow-copying/step7.png" /> {
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} /> text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/switch/allow-copying/step7.png",
<StartingPage emblaApi={emblaApi} /> },
{ type: "finish" },
{/* Create QR Code */} ],
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" /> },
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/create-qr-code/step2.png" /> {
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/switch/create-qr-code/step3.png" /> title: "Create QR Code",
<TutorialPage thumbnail: "/tutorial/switch/create-qr-code/thumbnail.png",
text="4. Select and press 'OK' on the Mii you wish to submit" steps: [
imageSrc="/tutorial/switch/create-qr-code/step4.png" { type: "start" },
/> {
<TutorialPage text: "1. Enter the town hall",
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission." imageSrc: "/tutorial/switch/step1.png",
imageSrc="/tutorial/switch/create-qr-code/step5.png" },
/> {
<TutorialPage text: "2. Go into 'QR Code'",
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)." imageSrc: "/tutorial/switch/create-qr-code/step2.png",
imageSrc="/tutorial/switch/create-qr-code/step6.png" },
/> {
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} /> text: "3. Press 'Create QR Code'",
</div> imageSrc: "/tutorial/switch/create-qr-code/step3.png",
</div> },
{
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}> text: "4. Select and press 'OK' on the Mii you wish to submit",
<button imageSrc: "/tutorial/switch/create-qr-code/step4.png",
onClick={() => emblaApi?.scrollPrev()} },
disabled={isStartingPage} {
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`} text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
aria-label="Scroll Carousel Left" imageSrc: "/tutorial/switch/create-qr-code/step5.png",
> },
<Icon icon="tabler:chevron-left" /> {
</button> text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/switch/create-qr-code/step6.png",
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span> },
{ type: "finish" },
<button ],
onClick={() => emblaApi?.scrollNext()} },
disabled={isStartingPage} ]}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`} isOpen={isOpen}
aria-label="Scroll Carousel Right" setIsOpen={setIsOpen}
> />,
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
document.body document.body
)} )}
</> </>

View file

@ -14,7 +14,7 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client"; import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [320, 240]; const MIN_IMAGE_DIMENSIONS = [128, 128];
const MAX_IMAGE_DIMENSIONS = [1920, 1080]; const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
@ -22,7 +22,11 @@ const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"
//#region Image validation //#region Image validation
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> { export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> {
if (!file || file.size == 0) return { valid: false, error: "Empty image file" }; if (!file || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` }; if (file.size > MAX_IMAGE_SIZE)
return {
valid: false,
error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB`,
};
try { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@ -30,7 +34,10 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
// Check mime type // Check mime type
const fileType = await fileTypeFromBuffer(buffer); const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" }; return {
valid: false,
error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed",
};
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
@ -48,7 +55,10 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
metadata.height < MIN_IMAGE_DIMENSIONS[1] || metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1] metadata.height > MAX_IMAGE_DIMENSIONS[1]
) { ) {
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 320x240 and 1920x1080" }; return {
valid: false,
error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080",
};
} }
// Check for inappropriate content // Check for inappropriate content
@ -62,7 +72,11 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) { if (!moderationResponse.ok) {
console.error("Moderation API error"); console.error("Moderation API error");
return { valid: false, error: "Content moderation check failed", status: 500 }; return {
valid: false,
error: "Content moderation check failed",
status: 500,
};
} }
const result = await moderationResponse.json(); const result = await moderationResponse.json();
@ -77,7 +91,11 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
return { valid: true }; return { valid: true };
} catch (error) { } catch (error) {
console.error("Error validating image:", error); console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 }; return {
valid: false,
error: "Failed to process image file.",
status: 500,
};
} }
} }
//#endregion //#endregion
@ -146,13 +164,21 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col"> <div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full"> <div tw="flex w-full">
{/* Mii image */} {/* Mii image */}
<div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}> <div
tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2"
style={{
backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);",
}}
>
<img <img
src={miiImage} src={miiImage}
width={248} width={248}
height={248} height={248}
tw="w-full h-full" tw="w-full h-full"
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} style={{
objectFit: "contain",
filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)",
}}
/> />
</div> </div>
@ -168,22 +194,35 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
{mii.name} {mii.name}
</span> </span>
{/* Tags */} {/* Tags */}
<div id="tags" tw="flex flex-wrap mt-1 w-full"> <div id="tags" tw="relative flex mt-1 w-full overflow-hidden">
{mii.tags.map((tag) => ( <div tw="flex">
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm"> {mii.tags.map((tag) => (
{tag} <span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm shrink-0">
</span> {tag}
))} </span>
))}
</div>
<div
tw="absolute inset-0"
style={{
position: "absolute",
backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);",
}}
></div>
</div> </div>
{/* Author */} {/* Author */}
<div tw="flex text-sm mt-2"> <div tw="flex mt-2 text-sm w-1/2">
By: <span tw="ml-1.5 font-semibold">@{author}</span> By{" "}
<span tw="ml-1.5 font-semibold overflow-hidden" style={{ textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{author}
</span>
</div> </div>
{/* Watermark */} {/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center"> <div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={34} /> <img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
{/* I tried using text-orange-400 but it wasn't correct..? */} {/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}> <span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare TomodachiShare

View file

@ -1,3 +1,4 @@
import { MiiGender, MiiPlatform } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny // profanity censoring bypasses the regex in some of these but I think it's funny
@ -26,7 +27,7 @@ export const tagsSchema = z
z z
.string() .string()
.min(2, { error: "Tags must be at least 2 characters long" }) .min(2, { error: "Tags must be at least 2 characters long" })
.max(64, { 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.",
}) })
@ -39,6 +40,37 @@ export const idSchema = z.coerce
.int({ error: "ID must be an integer" }) .int({ error: "ID must be an integer" })
.positive({ error: "ID must be valid" }); .positive({ error: "ID must be valid" });
export const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
tags: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
// Account Info // Account Info
export const usernameSchema = z export const usernameSchema = z
.string() .string()

10
src/types.d.ts vendored
View file

@ -12,13 +12,3 @@ declare module "next-auth" {
username?: string; username?: string;
} }
} }
type MiiWithUsername = Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}>;

View file

@ -1,28 +1,28 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"sjcl-with-all": ["./node_modules/@types/sjcl"] "sjcl-with-all": ["./node_modules/@types/sjcl"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }