mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
feat: filter menu and new allowed copying + tag exclude filter
also FINALLY fixes this annoying bug with tag selector
This commit is contained in:
parent
6d19988306
commit
3772a23ea6
14 changed files with 226 additions and 300 deletions
129
API.md
129
API.md
|
|
@ -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_
|
|
||||||
|
|
@ -80,7 +80,7 @@ model Mii {
|
||||||
lastName String
|
lastName String
|
||||||
gender MiiGender?
|
gender MiiGender?
|
||||||
islandName String
|
islandName String
|
||||||
allowedCopying Boolean
|
allowedCopying Boolean?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,9 +25,12 @@ body {
|
||||||
--color1: var(--color-amber-50);
|
--color1: var(--color-amber-50);
|
||||||
--color2: var(--color-amber-100);
|
--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));
|
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;
|
background-size: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +67,13 @@ body {
|
||||||
@apply block;
|
@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] {
|
[data-tooltip] {
|
||||||
@apply relative z-10;
|
@apply relative z-10;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
117
src/components/mii-list/filter-menu.tsx
Normal file
117
src/components/mii-list/filter-menu.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="relative">
|
||||||
|
<button className="pill button gap-2" onClick={handleClick}>
|
||||||
|
<Icon icon="mdi:filter" className="text-xl" />
|
||||||
|
Filter
|
||||||
|
<span className="w-5">({filterCount})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
|
||||||
|
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
|
||||||
|
>
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Tags Include</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<TagFilter />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Tags Exclude</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<TagFilter isExclude />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Gender</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<GenderSelect />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Other</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<OtherFilters />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ export default function GenderSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import Carousel from "../carousel";
|
||||||
import LikeButton from "../like-button";
|
import LikeButton from "../like-button";
|
||||||
import DeleteMiiButton from "../delete-mii";
|
import DeleteMiiButton from "../delete-mii";
|
||||||
import Pagination from "./pagination";
|
import Pagination from "./pagination";
|
||||||
|
import FilterMenu from "./filter-menu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: { [key: string]: string | string[] | undefined };
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
|
|
@ -30,7 +31,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
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, 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
|
// My Likes page
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
@ -52,8 +53,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
}),
|
}),
|
||||||
// Tag filtering
|
// Tag filtering
|
||||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||||
|
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||||
// Gender
|
// Gender
|
||||||
...(gender && { gender: { equals: gender } }),
|
...(gender && { gender: { equals: gender } }),
|
||||||
|
// Allow Copying
|
||||||
|
...(allowCopying && { allowedCopying: true }),
|
||||||
// Profiles
|
// Profiles
|
||||||
...(userId && { userId }),
|
...(userId && { userId }),
|
||||||
};
|
};
|
||||||
|
|
@ -74,6 +78,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
tags: true,
|
tags: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
gender: true,
|
gender: true,
|
||||||
|
allowedCopying: true,
|
||||||
// Mii liked check
|
// Mii liked check
|
||||||
...(session?.user?.id && {
|
...(session?.user?.id && {
|
||||||
likedBy: {
|
likedBy: {
|
||||||
|
|
@ -171,9 +176,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 w-full min-[56rem]:max-w-2/3 max-[56rem]:justify-center max-sm:flex-col">
|
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||||
<GenderSelect />
|
<FilterMenu />
|
||||||
<TagFilter />
|
|
||||||
<SortSelect />
|
<SortSelect />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
38
src/components/mii-list/other-filters.tsx
Normal file
38
src/components/mii-list/other-filters.tsx
Normal file
|
|
@ -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<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||||
|
|
||||||
|
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<label htmlFor="allowCopying" className="text-sm">
|
||||||
|
Allow Copying
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" name="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ export default function SortSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -54,11 +54,7 @@ export default function SortSelect() {
|
||||||
>
|
>
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
items.map((item, index) => (
|
items.map((item, index) => (
|
||||||
<li
|
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||||
key={item}
|
|
||||||
{...getItemProps({ item, index })}
|
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
|
||||||
>
|
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import TagSelector from "../tag-selector";
|
import TagSelector from "../tag-selector";
|
||||||
|
|
||||||
export default function TagFilter() {
|
interface Props {
|
||||||
|
isExclude?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagFilter({ isExclude }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const rawTags = searchParams.get("tags") || "";
|
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||||
const preexistingTags = useMemo(
|
const preexistingTags = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rawTags
|
rawTags
|
||||||
|
|
@ -18,7 +22,7 @@ export default function TagFilter() {
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter((tag) => tag.length > 0)
|
.filter((tag) => tag.length > 0)
|
||||||
: [],
|
: [],
|
||||||
[rawTags]
|
[rawTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||||
|
|
@ -39,20 +43,20 @@ export default function TagFilter() {
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
params.set("tags", stateTags);
|
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||||
} else {
|
} else {
|
||||||
params.delete("tags");
|
params.delete(isExclude ? "exclude" : "tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72">
|
<div className="w-72">
|
||||||
<TagSelector tags={tags} setTags={setTags} />
|
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function SearchBar() {
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const result = querySchema.safeParse(query);
|
const result = querySchema.safeParse(query);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
router.push("/");
|
router.push("/", { scroll: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default function SearchBar() {
|
||||||
params.set("q", query);
|
params.set("q", query);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
router.push(`/?${params.toString()}`);
|
router.push(`/?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ interface Props {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
showTagLimit?: boolean;
|
showTagLimit?: boolean;
|
||||||
|
isExclude?: 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, showTagLimit }: Props) {
|
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) {
|
||||||
const [inputValue, setInputValue] = useState<string>("");
|
const [inputValue, setInputValue] = useState<string>("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -37,26 +38,36 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||||
inputValue,
|
inputValue,
|
||||||
items: filteredItems,
|
items: filteredItems,
|
||||||
|
selectedItem: null,
|
||||||
onInputValueChange: ({ inputValue }) => {
|
onInputValueChange: ({ inputValue }) => {
|
||||||
if (inputValue && !tagRegex.test(inputValue)) return;
|
const newValue = inputValue || "";
|
||||||
setInputValue(inputValue || "");
|
if (newValue && !tagRegex.test(newValue)) return;
|
||||||
|
setInputValue(newValue);
|
||||||
},
|
},
|
||||||
onStateChange: ({ type, selectedItem }) => {
|
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||||
addTag(selectedItem);
|
addTag(selectedItem);
|
||||||
setInputValue("");
|
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<HTMLInputElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||||
addTag(inputValue);
|
addTag(inputValue);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
}
|
} else if (event.key === "Backspace" && inputValue === "") {
|
||||||
|
|
||||||
// Spill onto last tag
|
// Spill onto last tag
|
||||||
if (event.key === "Backspace" && inputValue === "") {
|
|
||||||
const lastTag = tags[tags.length - 1];
|
const lastTag = tags[tags.length - 1];
|
||||||
setInputValue(lastTag);
|
setInputValue(lastTag);
|
||||||
removeTag(lastTag);
|
removeTag(lastTag);
|
||||||
|
|
@ -81,7 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex flex-wrap gap-1.5 w-full">
|
<div className="flex flex-wrap gap-1.5 w-full">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
|
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,12 @@ export const tagsSchema = z
|
||||||
.max(20, { 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.",
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.min(1, { error: "There must be at least 1 tag" })
|
.min(1, { error: "There must be at least 1 tag" })
|
||||||
.max(8, { error: "There cannot be more than 8 tags" });
|
.max(8, { error: "There cannot be more than 8 tags" });
|
||||||
|
|
||||||
export const idSchema = z.coerce
|
export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" });
|
||||||
.number({ error: "ID must be a number" })
|
|
||||||
.int({ error: "ID must be an integer" })
|
|
||||||
.positive({ error: "ID must be valid" });
|
|
||||||
|
|
||||||
export const searchSchema = z.object({
|
export const searchSchema = z.object({
|
||||||
q: querySchema.optional(),
|
q: querySchema.optional(),
|
||||||
|
|
@ -50,9 +47,19 @@ export const searchSchema = z.object({
|
||||||
value
|
value
|
||||||
?.split(",")
|
?.split(",")
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter((tag) => tag.length > 0)
|
.filter((tag) => tag.length > 0),
|
||||||
|
),
|
||||||
|
exclude: 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(),
|
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
||||||
|
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
||||||
// todo: incorporate tagsSchema
|
// todo: incorporate tagsSchema
|
||||||
// Pages
|
// Pages
|
||||||
limit: z.coerce
|
limit: z.coerce
|
||||||
|
|
@ -61,11 +68,7 @@ export const searchSchema = z.object({
|
||||||
.min(1, { error: "Limit must be at least 1" })
|
.min(1, { error: "Limit must be at least 1" })
|
||||||
.max(100, { error: "Limit cannot be more than 100" })
|
.max(100, { error: "Limit cannot be more than 100" })
|
||||||
.optional(),
|
.optional(),
|
||||||
page: z.coerce
|
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(),
|
||||||
.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
|
// Random sort
|
||||||
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue