diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..ebdecb7
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "tabWidth": 2,
+ "useTabs": true,
+ "printWidth": 160
+}
\ No newline at end of file
diff --git a/API.md b/API.md
new file mode 100644
index 0000000..eb18ec5
--- /dev/null
+++ b/API.md
@@ -0,0 +1,130 @@
+# TomodachiShare API Reference
+
+Welcome to the TomodachiShare API Reference!
+Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_).
+
+## Public Endpoints
+
+### **Search Miis**
+
+`GET /api/search?q={query}`
+
+Searches Miis by name, tags, and description.
+
+#### **Query Parameters**
+
+| Name | Type | Required | Description |
+| ------ | ------ | -------- | ----------------------------------------------------------------- |
+| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. |
+| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. |
+| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. |
+| gender | string | No | Gender filter: `MALE` or `FEMALE`. |
+| limit | number | No | Number of results per page (1-100). |
+| page | number | No | Page number. Defaults to `1`. |
+| seed | number | No | Seed used for `random` sorting to ensure unique results per page. |
+
+#### **Examples**
+
+```
+https://tomodachishare.com/api/search?q=frieren
+```
+
+```
+https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204
+```
+
+#### **Response**
+
+Returns an array of Mii IDs:
+
+```json
+[1, 204, 295, 1024]
+```
+
+When no Miis are found:
+
+```json
+{ "error": "No Miis found!" }
+```
+
+---
+
+### **Get Mii Image / QR Code / Metadata Image**
+
+`GET /mii/{id}/image?type={type}`
+
+Retrieves the Mii image, QR code, or metadata graphic.
+
+#### **Path & Query Parameters**
+
+| Name | Type | Required | Description |
+| -------- | ------ | -------- | ------------------------------------- |
+| **id** | number | **Yes** | The Mii’s ID. |
+| **type** | string | **Yes** | One of: `mii`, `qr-code`, `metadata`. |
+
+#### **Examples**
+
+```
+https://tomodachishare.com/mii/1/image?type=mii
+```
+
+```
+https://tomodachishare.com/mii/2/image?type=qr-code
+```
+
+```
+https://tomodachishare.com/mii/3/image?type=metadata
+```
+
+#### **Response**
+
+Returns the image file.
+
+---
+
+### **Get Mii Data**
+
+`GET /mii/{id}/data`
+
+Fetches metadata for a specific Mii.
+
+#### **Path Parameters**
+
+| Name | Type | Required | Description |
+| ------ | ------ | -------- | ------------- |
+| **id** | number | **Yes** | The Mii’s ID. |
+
+#### **Example**
+
+```
+https://tomodachishare.com/mii/1/data
+```
+
+#### **Response**
+
+```json
+{
+ "id": 1,
+ "name": "Frieren",
+ "platform": "THREE_DS",
+ "imageCount": 3,
+ "tags": ["anime", "frieren"],
+ "description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!",
+ "firstName": "Frieren",
+ "lastName": "the Slayer",
+ "gender": "FEMALE",
+ "islandName": "Wuhu",
+ "allowedCopying": false,
+ "createdAt": "2025-05-04T12:29:41Z",
+ "user": {
+ "id": 1,
+ "username": "trafficlunar",
+ "name": "trafficlunar"
+ },
+ "likes": 29
+}
+```
+
+## Protected Endpoints
+
+_TODO_
diff --git a/DEVELOPMENT.MD b/DEVELOPMENT.md
similarity index 78%
rename from DEVELOPMENT.MD
rename to DEVELOPMENT.md
index a891655..8cdcf26 100644
--- a/DEVELOPMENT.MD
+++ b/DEVELOPMENT.md
@@ -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.
-Note: this project is intended to be used on Linux - in production and development.
-
## Getting started
To get the project up and running locally, follow these steps:
@@ -14,7 +12,7 @@ $ cd tomodachi-share
$ 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
# Generate Prisma client types
@@ -25,6 +23,12 @@ $ pnpm prisma migrate dev
$ 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
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
```
-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:
@@ -61,7 +71,10 @@ For the `AUTH_SECRET`, run the following in the command line:
$ 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`.
diff --git a/README.md b/README.md
index a068f35..54cd8bf 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,9 @@
- 🌎 Browse and add Miis from other players
- 🏝️ Build your perfect island by finding the perfect residents
-### Development Instructions
+### Development Instructions
+
+### API Reference
---
diff --git a/prisma/migrations/20251110183556_profile_descriptions/migration.sql b/prisma/migrations/20251110183556_profile_descriptions/migration.sql
new file mode 100644
index 0000000..f039b27
--- /dev/null
+++ b/prisma/migrations/20251110183556_profile_descriptions/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b446e23..cfda173 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,6 +14,7 @@ model User {
email String @unique
emailVerified DateTime?
image String?
+ description String? @db.VarChar(256)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 5b0a5f1..66af4c6 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form";
import ControlCenter from "@/components/admin/control-center";
+import RegenerateImagesButton from "@/components/admin/regenerate-images";
import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports";
@@ -31,36 +32,37 @@ export default async function AdminPage() {
{/* Separator */}
-
+
Banners
-
+
{/* Separator */}
-
+
Control Center
-
+
+
{/* Separator */}
-
+
User Management
-
+
{/* Separator */}
-
+
Reports
-
+
diff --git a/src/app/api/admin/regenerate-metadata-images/route.ts b/src/app/api/admin/regenerate-metadata-images/route.ts
new file mode 100644
index 0000000..86209f0
--- /dev/null
+++ b/src/app/api/admin/regenerate-metadata-images/route.ts
@@ -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);
+ }
+ }
+ }
+}
diff --git a/src/app/api/auth/about-me/route.ts b/src/app/api/auth/about-me/route.ts
new file mode 100644
index 0000000..b6f8e11
--- /dev/null
+++ b/src/app/api/auth/about-me/route.ts
@@ -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 });
+}
diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts
index 293714b..3c63c97 100644
--- a/src/app/api/mii/[id]/edit/route.ts
+++ b/src/app/api/mii/[id]/edit/route.ts
@@ -44,13 +44,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
where: {
id: miiId,
},
- include: {
- user: {
- select: {
- username: true,
- },
- },
- },
});
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 (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
- await prisma.mii.update({
+ const updatedMii = await prisma.mii.update({
where: {
id: miiId,
},
data: updateData,
+ include: {
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ },
});
// 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) {
// If images or description were not changed, regenerate the metadata image
try {
- await generateMetadataImage(mii, mii.user.username!);
+ await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts
index 7f065d0..f3249a1 100644
--- a/src/app/api/report/route.ts
+++ b/src/app/api/report/route.ts
@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { ReportReason, ReportType } from "@prisma/client";
+import { Prisma, ReportReason, ReportType } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
-import { MiiWithUsername } from "@/types";
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" }),
@@ -30,7 +29,15 @@ export async function POST(request: NextRequest) {
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
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
if (type === "mii") {
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 0000000..2e219a2
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -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));
+ }
+}
diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts
index 7f7a750..0d00b58 100644
--- a/src/app/api/submit/route.ts
+++ b/src/app/api/submit/route.ts
@@ -37,16 +37,14 @@ const submitSchema = z
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// 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" })
- .optional(),
+ 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" }).optional(),
// Custom images
image1: 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(),
})
+ // This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, accessKey, gender, and miiPortraitImage must be present
@@ -230,10 +228,15 @@ export async function POST(request: NextRequest) {
}
try {
- await generateMetadataImage(miiRecord, session.user.username!);
+ await generateMetadataImage(miiRecord, session.user.name!);
} catch (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
diff --git a/src/app/create-username/page.tsx b/src/app/create-username/page.tsx
index 05e8cf7..3554e71 100644
--- a/src/app/create-username/page.tsx
+++ b/src/app/create-username/page.tsx
@@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
}
return (
-
+
Welcome to the island!
-
+
Please create a username
-
+
diff --git a/src/app/globals.css b/src/app/globals.css
index 8dc99a6..bcf27ad 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -32,7 +32,7 @@ body {
}
.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 {
@@ -40,15 +40,15 @@ body {
}
.button:disabled {
- @apply text-zinc-600 !bg-zinc-100 !border-zinc-300 cursor-auto;
+ @apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
}
.input {
- @apply !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
+ @apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
}
.input:disabled {
- @apply text-zinc-600 !bg-zinc-100 !border-zinc-300;
+ @apply text-zinc-600 bg-zinc-100! border-zinc-300!;
}
.checkbox {
@@ -88,7 +88,7 @@ body {
}
[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 {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index a7fabf0..e73dacd 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -92,7 +92,7 @@ export default function RootLayout({
- {children}
+ {children}