mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44: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
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue