From 3772a23ea66b03fcec79ee67d67b0debce132740 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sun, 1 Feb 2026 20:08:56 +0000 Subject: [PATCH] feat: filter menu and new allowed copying + tag exclude filter also FINALLY fixes this annoying bug with tag selector --- API.md | 129 ---------------------- prisma/schema.prisma | 2 +- src/app/api/search/route.ts | 79 ------------- src/app/globals.css | 14 ++- src/app/mii/[id]/data/route.ts | 49 -------- src/components/mii-list/filter-menu.tsx | 117 ++++++++++++++++++++ src/components/mii-list/gender-select.tsx | 2 +- src/components/mii-list/index.tsx | 12 +- src/components/mii-list/other-filters.tsx | 38 +++++++ src/components/mii-list/sort-select.tsx | 8 +- src/components/mii-list/tag-filter.tsx | 18 +-- src/components/search-bar.tsx | 4 +- src/components/tag-selector.tsx | 29 +++-- src/lib/schemas.ts | 25 +++-- 14 files changed, 226 insertions(+), 300 deletions(-) delete mode 100644 API.md delete mode 100644 src/app/api/search/route.ts delete mode 100644 src/app/mii/[id]/data/route.ts create mode 100644 src/components/mii-list/filter-menu.tsx create mode 100644 src/components/mii-list/other-filters.tsx 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 ( +
+ + + {isOpen && ( +
+ {/* Arrow */} +
+ +
+
+ Tags Include +
+
+ + +
+
+ Tags Exclude +
+
+ + +
+
+ Gender +
+
+ + +
+
+ Other +
+
+ +
+ )} +
+ ); +} diff --git a/src/components/mii-list/gender-select.tsx b/src/components/mii-list/gender-select.tsx index 2af2a8d..2a9c0d3 100644 --- a/src/components/mii-list/gender-select.tsx +++ b/src/components/mii-list/gender-select.tsx @@ -26,7 +26,7 @@ export default function GenderSelect() { } startTransition(() => { - router.push(`?${params.toString()}`); + router.push(`?${params.toString()}`, { scroll: false }); }); }; diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 5794cbb..dd8d185 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii-list/index.tsx @@ -17,6 +17,7 @@ import Carousel from "../carousel"; import LikeButton from "../like-button"; import DeleteMiiButton from "../delete-mii"; import Pagination from "./pagination"; +import FilterMenu from "./filter-menu"; interface Props { searchParams: { [key: string]: string | string[] | undefined }; @@ -30,7 +31,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro const parsed = searchSchema.safeParse(searchParams); if (!parsed.success) return

{parsed.error.issues[0].message}

; - const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data; + const { q: query, sort, tags, exclude, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data; // My Likes page let miiIdsLiked: number[] | undefined = undefined; @@ -52,8 +53,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro }), // Tag filtering ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), + ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), // Gender ...(gender && { gender: { equals: gender } }), + // Allow Copying + ...(allowCopying && { allowedCopying: true }), // Profiles ...(userId && { userId }), }; @@ -74,6 +78,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro tags: true, createdAt: true, gender: true, + allowedCopying: true, // Mii liked check ...(session?.user?.id && { likedBy: { @@ -171,9 +176,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro )} -
- - +
+
diff --git a/src/components/mii-list/other-filters.tsx b/src/components/mii-list/other-filters.tsx new file mode 100644 index 0000000..8a2a346 --- /dev/null +++ b/src/components/mii-list/other-filters.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import React, { ChangeEvent, ChangeEventHandler, useState, useTransition } from "react"; + +export default function OtherFilters() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const [allowCopying, setAllowCopying] = useState((searchParams.get("allowCopying") as unknown as boolean) ?? false); + + const handleChangeAllowCopying = (e: ChangeEvent) => { + setAllowCopying(e.target.checked); + + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + + if (!allowCopying) { + params.set("allowCopying", "true"); + } else { + params.delete("allowCopying"); + } + + startTransition(() => { + router.push(`?${params.toString()}`, { scroll: false }); + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/src/components/mii-list/sort-select.tsx b/src/components/mii-list/sort-select.tsx index 233295c..e62ed01 100644 --- a/src/components/mii-list/sort-select.tsx +++ b/src/components/mii-list/sort-select.tsx @@ -31,7 +31,7 @@ export default function SortSelect() { } startTransition(() => { - router.push(`?${params.toString()}`); + router.push(`?${params.toString()}`, { scroll: false }); }); }, }); @@ -54,11 +54,7 @@ export default function SortSelect() { > {isOpen && items.map((item, index) => ( -
  • +
  • {item}
  • ))} diff --git a/src/components/mii-list/tag-filter.tsx b/src/components/mii-list/tag-filter.tsx index 7607a0b..4cbbb8c 100644 --- a/src/components/mii-list/tag-filter.tsx +++ b/src/components/mii-list/tag-filter.tsx @@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; import TagSelector from "../tag-selector"; -export default function TagFilter() { +interface Props { + isExclude?: boolean; +} + +export default function TagFilter({ isExclude }: Props) { const router = useRouter(); const searchParams = useSearchParams(); const [, startTransition] = useTransition(); - const rawTags = searchParams.get("tags") || ""; + const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || ""; const preexistingTags = useMemo( () => rawTags @@ -18,7 +22,7 @@ export default function TagFilter() { .map((tag) => tag.trim()) .filter((tag) => tag.length > 0) : [], - [rawTags] + [rawTags], ); const [tags, setTags] = useState(preexistingTags); @@ -39,20 +43,20 @@ export default function TagFilter() { params.set("page", "1"); if (tags.length > 0) { - params.set("tags", stateTags); + params.set(isExclude ? "exclude" : "tags", stateTags); } else { - params.delete("tags"); + params.delete(isExclude ? "exclude" : "tags"); } startTransition(() => { - router.push(`?${params.toString()}`); + router.push(`?${params.toString()}`, { scroll: false }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [tags]); return (
    - +
    ); } diff --git a/src/components/search-bar.tsx b/src/components/search-bar.tsx index e48eb3e..044a0a1 100644 --- a/src/components/search-bar.tsx +++ b/src/components/search-bar.tsx @@ -13,7 +13,7 @@ export default function SearchBar() { const handleSearch = () => { const result = querySchema.safeParse(query); if (!result.success) { - router.push("/"); + router.push("/", { scroll: false }); return; } @@ -22,7 +22,7 @@ export default function SearchBar() { params.set("q", query); params.set("page", "1"); - router.push(`/?${params.toString()}`); + router.push(`/?${params.toString()}`, { scroll: false }); }; const handleKeyDown = (event: React.KeyboardEvent) => { diff --git a/src/components/tag-selector.tsx b/src/components/tag-selector.tsx index 1bf6931..d660a5f 100644 --- a/src/components/tag-selector.tsx +++ b/src/components/tag-selector.tsx @@ -8,12 +8,13 @@ interface Props { tags: string[]; setTags: React.Dispatch>; showTagLimit?: boolean; + isExclude?: boolean; } const tagRegex = /^[a-z0-9-_]*$/; const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"]; -export default function TagSelector({ tags, setTags, showTagLimit }: Props) { +export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) { const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); @@ -37,26 +38,36 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) { const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox({ inputValue, items: filteredItems, + selectedItem: null, onInputValueChange: ({ inputValue }) => { - if (inputValue && !tagRegex.test(inputValue)) return; - setInputValue(inputValue || ""); + const newValue = inputValue || ""; + if (newValue && !tagRegex.test(newValue)) return; + setInputValue(newValue); }, - onStateChange: ({ type, selectedItem }) => { + onSelectedItemChange: ({ type, selectedItem }) => { if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) { addTag(selectedItem); setInputValue(""); } }, + stateReducer: (_, { type, changes }) => { + // Prevent input from being filled when item is selected + if (type === useCombobox.stateChangeTypes.ItemClick) { + return { + ...changes, + inputValue: "", + }; + } + return changes; + }, }); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) { addTag(inputValue); setInputValue(""); - } - - // Spill onto last tag - if (event.key === "Backspace" && inputValue === "") { + } else if (event.key === "Backspace" && inputValue === "") { + // Spill onto last tag const lastTag = tags[tags.length - 1]; setInputValue(lastTag); removeTag(lastTag); @@ -81,7 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) { {/* Tags */}
    {tags.map((tag) => ( - + {tag}