diff --git a/API.md b/API.md deleted file mode 100644 index 87edda1..0000000 --- a/API.md +++ /dev/null @@ -1,129 +0,0 @@ -# TomodachiShare API Reference - -Welcome to the TomodachiShare API Reference! -Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_). - -## Public Endpoints - -### **Search Miis** - -`GET /api/search?q={query}` - -Searches Miis by name, tags, and description. - -#### **Query Parameters** - -| Name | Type | Required | Description | -| ------ | ------ | -------- | ----------------------------------------------------------------- | -| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. | -| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. | -| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. | -| gender | string | No | Gender filter: `MALE` or `FEMALE`. | -| limit | number | No | Number of results per page (1-100). | -| page | number | No | Page number. Defaults to `1`. | -| seed | number | No | Seed used for `random` sorting to ensure unique results per page. | - -#### **Examples** - -``` -https://tomodachishare.com/api/search?q=frieren -``` - -``` -https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204 -``` - -#### **Response** - -Returns an array of Mii IDs: - -```json -[1, 204, 295, 1024] -``` - -When no Miis are found: - -```json -{ "error": "No Miis found!" } -``` - ---- - -### **Get Mii Image / QR Code / Metadata Image** - -`GET /mii/{id}/image?type={type}` - -Retrieves the Mii image, QR code, or metadata graphic. - -#### **Path & Query Parameters** - -| Name | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------- | -| **id** | number | **Yes** | The 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/prisma/schema.prisma b/prisma/schema.prisma index 9be57ec..7c029bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,7 +80,7 @@ model Mii { lastName String gender MiiGender? islandName String - allowedCopying Boolean + allowedCopying Boolean? createdAt DateTime @default(now()) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts deleted file mode 100644 index 2e219a2..0000000 --- a/src/app/api/search/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextRequest } from "next/server"; - -import crypto from "crypto"; -import seedrandom from "seedrandom"; - -import { searchSchema } from "@/lib/schemas"; -import { RateLimit } from "@/lib/rate-limit"; -import { prisma } from "@/lib/prisma"; -import { Prisma } from "@prisma/client"; - -export async function GET(request: NextRequest) { - const rateLimit = new RateLimit(request, 24, "/api/search"); - const check = await rateLimit.handle(); - if (check) return check; - - const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); - if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); - - const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data; - - const where: Prisma.MiiWhereInput = { - // Searching - ...(query && { - OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }], - }), - // Tag filtering - ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), - // Gender - ...(gender && { gender: { equals: gender } }), - }; - - const skip = (page - 1) * limit; - - if (sort === "random") { - // Use seed for consistent random results - const randomSeed = seed || crypto.randomInt(0, 1_000_000_000); - - // Get all IDs that match the where conditions - const matchingIds = await prisma.mii.findMany({ - where, - select: { id: true }, - }); - - if (matchingIds.length === 0) return rateLimit.sendResponse({ error: "No Miis found!" }, 404); - - const rng = seedrandom(randomSeed.toString()); - - // Randomize all IDs using the Durstenfeld algorithm - for (let i = matchingIds.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]]; - } - - // Convert to number[] array and return paginated results - return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id)); - } else { - // Sorting by likes, newest, or oldest - let orderBy: Prisma.MiiOrderByWithRelationInput[]; - - if (sort === "likes") { - orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }]; - } else if (sort === "oldest") { - orderBy = [{ createdAt: "asc" }, { name: "asc" }]; - } else { - // default to newest - orderBy = [{ createdAt: "desc" }, { name: "asc" }]; - } - - const list = await prisma.mii.findMany({ - where, - orderBy, - select: { id: true }, - skip, - take: limit, - }); - - return rateLimit.sendResponse(list.map((mii) => mii.id)); - } -} diff --git a/src/app/globals.css b/src/app/globals.css index b49b1bd..ac3d5b8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -25,9 +25,12 @@ body { --color1: var(--color-amber-50); --color2: var(--color-amber-100); - background-image: repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)), + background-image: + repeating-linear-gradient(45deg, var(--color1) 25%, transparent 25%, transparent 75%, var(--color1) 75%, var(--color1)), repeating-linear-gradient(45deg, var(--color1) 25%, var(--color2) 25%, var(--color2) 75%, var(--color1) 75%, var(--color1)); - background-position: 0 0, 10px 10px; + background-position: + 0 0, + 10px 10px; background-size: 20px 20px; } @@ -64,6 +67,13 @@ body { @apply block; } +.checkbox-alt { + @apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all + after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5 + after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px] + checked:hover:bg-orange-500 ml-auto; +} + [data-tooltip] { @apply relative z-10; } diff --git a/src/app/mii/[id]/data/route.ts b/src/app/mii/[id]/data/route.ts deleted file mode 100644 index 6c20aa4..0000000 --- a/src/app/mii/[id]/data/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextRequest } from "next/server"; - -import { idSchema } from "@/lib/schemas"; -import { RateLimit } from "@/lib/rate-limit"; -import { prisma } from "@/lib/prisma"; - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const rateLimit = new RateLimit(request, 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/filter-menu.tsx b/src/components/mii-list/filter-menu.tsx new file mode 100644 index 0000000..848e607 --- /dev/null +++ b/src/components/mii-list/filter-menu.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { Icon } from "@iconify/react"; + +import { MiiGender } from "@prisma/client"; + +import TagFilter from "./tag-filter"; +import GenderSelect from "./gender-select"; +import OtherFilters from "./other-filters"; + +export default function FilterMenu() { + const searchParams = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const rawTags = searchParams.get("tags") || ""; + const rawExclude = searchParams.get("exclude") || ""; + const gender = (searchParams.get("gender") as MiiGender) || undefined; + const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false; + + const tags = useMemo( + () => + rawTags + ? rawTags + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + : [], + [rawTags], + ); + const exclude = useMemo( + () => + rawExclude + ? rawExclude + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + : [], + [rawExclude], + ); + + const [filterCount, setFilterCount] = useState(tags.length); + + // Filter menu button handler + const handleClick = () => { + if (!isOpen) { + setIsOpen(true); + // slight delay to trigger animation + setTimeout(() => setIsVisible(true), 10); + } else { + setIsVisible(false); + setTimeout(() => { + setIsOpen(false); + }, 200); + } + }; + + // Count all active filters + useEffect(() => { + let count = tags.length + exclude.length; + if (gender) count++; + if (allowCopying) count++; + + setFilterCount(count); + }, [tags, exclude, gender, allowCopying]); + + return ( +