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

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

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(() => {
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>
))}

View file

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

View file

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

View file

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