mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 19:23:15 +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
|
||||
gender MiiGender?
|
||||
islandName String
|
||||
allowedCopying Boolean
|
||||
allowedCopying Boolean?
|
||||
|
||||
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);
|
||||
--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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
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
|
|||
)}
|
||||
</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">
|
||||
<GenderSelect />
|
||||
<TagFilter />
|
||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||
<FilterMenu />
|
||||
<SortSelect />
|
||||
</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(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -54,11 +54,7 @@ export default function SortSelect() {
|
|||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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<string[]>(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 (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ interface Props {
|
|||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
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<string>("");
|
||||
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>({
|
||||
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<HTMLInputElement>) => {
|
||||
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 */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{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}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -30,15 +30,12 @@ export const tagsSchema = z
|
|||
.max(20, { error: "Tags cannot be more than 20 characters long" })
|
||||
.regex(/^[a-z0-9-_]+$/, {
|
||||
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
.min(1, { error: "There must be at least 1 tag" })
|
||||
.max(8, { error: "There cannot be more than 8 tags" });
|
||||
|
||||
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" });
|
||||
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" });
|
||||
|
||||
export const searchSchema = z.object({
|
||||
q: querySchema.optional(),
|
||||
|
|
@ -50,9 +47,19 @@ export const searchSchema = z.object({
|
|||
value
|
||||
?.split(",")
|
||||
.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(),
|
||||
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
||||
// todo: incorporate tagsSchema
|
||||
// Pages
|
||||
limit: z.coerce
|
||||
|
|
@ -61,11 +68,7 @@ export const searchSchema = z.object({
|
|||
.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(),
|
||||
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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue