diff --git a/API.md b/API.md
new file mode 100644
index 0000000..9913508
--- /dev/null
+++ b/API.md
@@ -0,0 +1,125 @@
+# TomodachiShare API Reference
+
+Welcome to the TomodachiShare API Reference!
+Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_).
+
+Schema properties marked with a **\*** are required.
+
+## 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={query}
+```
+
+```
+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]
+```
+
+---
+
+### **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",
+ "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 100%
rename from DEVELOPMENT.MD
rename to DEVELOPMENT.md
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/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 0000000..57c7e35
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,92 @@
+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 select: Prisma.MiiSelect = {
+ id: true,
+ name: true,
+ imageCount: true,
+ tags: true,
+ createdAt: true,
+ gender: true,
+ // Like count
+ _count: {
+ select: { likedBy: true },
+ },
+ };
+
+ 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;
+
+ 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/mii/[id]/data/route.ts b/src/app/mii/[id]/data/route.ts
new file mode 100644
index 0000000..6c20aa4
--- /dev/null
+++ b/src/app/mii/[id]/data/route.ts
@@ -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,
+ });
+}
diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx
index 72486d6..5794cbb 100644
--- a/src/components/mii-list/index.tsx
+++ b/src/components/mii-list/index.tsx
@@ -1,13 +1,12 @@
import Link from "next/link";
-import { MiiGender, Prisma } from "@prisma/client";
+import { Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
-import { z } from "zod";
import crypto from "crypto";
import seedrandom from "seedrandom";
-import { querySchema } from "@/lib/schemas";
+import { searchSchema } from "@/lib/schemas";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -25,36 +24,6 @@ interface Props {
inLikesPage?: boolean; // Self-explanatory
}
-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)
- ),
- 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();
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 102ac10..4bc06bb 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -1,3 +1,4 @@
+import { MiiGender } from "@prisma/client";
import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny
@@ -39,6 +40,36 @@ export const idSchema = z.coerce
.int({ error: "ID must be an integer" })
.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)
+ ),
+ 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
export const usernameSchema = z
.string()