feat: filter menu and new allowed copying + tag exclude filter

also FINALLY fixes this annoying bug with tag selector
This commit is contained in:
trafficlunar 2026-02-01 20:08:56 +00:00
parent 6d19988306
commit 3772a23ea6
14 changed files with 226 additions and 300 deletions

129
API.md
View file

@ -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 Miis 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 Miis 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_

View file

@ -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())

View file

@ -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));
}
}

View file

@ -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;
} }

View file

@ -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,
});
}

View 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>
);
}

View file

@ -26,7 +26,7 @@ export default function GenderSelect() {
} }
startTransition(() => { startTransition(() => {
router.push(`?${params.toString()}`); router.push(`?${params.toString()}`, { scroll: false });
}); });
}; };

View file

@ -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>

View 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>
);
}

View file

@ -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>
))} ))}

View file

@ -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>
); );
} }

View file

@ -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) => {

View file

@ -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"

View file

@ -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(),
}); });