feat: improve mii list filtering, add gender filter

- added react transitions when redirecting, should improve UI looks and
smoothness when filtering/sorting
- small refactor - put @types/sjcl in devDependencies
This commit is contained in:
trafficlunar 2025-07-19 17:12:38 +01:00
parent 1a14683a10
commit e1a158d070
11 changed files with 141 additions and 93 deletions

View file

@ -1,62 +0,0 @@
"use client";
import { redirect, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import TagSelector from "../tag-selector";
export default function FilterSelect() {
const searchParams = useSearchParams();
const rawTags = searchParams.get("tags");
const preexistingTags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
);
const [isOpen, setIsOpen] = useState(false);
const [tags, setTags] = useState<string[]>(preexistingTags);
const handleSubmit = () => {
redirect(`/?tags=${encodeURIComponent(tags.join(","))}`);
};
useEffect(() => {
setTags(preexistingTags);
}, [preexistingTags]);
return (
<div className="relative">
<button onClick={() => setIsOpen((prev) => !prev)} aria-label="Filter dropdown" className="pill button gap-1 text-nowrap">
Filter{" "}
{tags.length > 0 ? (
<span>
({tags.length} {tags.length == 1 ? "filter" : "filters"})
</span>
) : (
""
)}
</button>
<div
className={`absolute z-40 left-1/2 -translate-x-1/2 w-96 bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg flex flex-col justify-between gap-2 p-2 max-[32rem]:-left-8 max-[32rem]:w-80 max-[32rem]:translate-x-0 ${
isOpen ? "block" : "hidden"
}`}
>
<div>
<label className="text-sm ml-2">Tags</label>
<TagSelector tags={tags} setTags={setTags} />
</div>
<button onClick={handleSubmit} className="pill button text-sm !px-3 !py-0.5 w-min">
Submit
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,54 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import { MiiGender } from "@prisma/client";
export default function GenderSelect() {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
setSelected(filter);
const params = new URLSearchParams(searchParams);
if (filter) {
params.set("gender", filter);
} else {
params.delete("gender");
}
startTransition(() => {
router.push(`?${params.toString()}`);
});
};
return (
<div className="grid grid-cols-2 gap-0.5">
<button
onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>
);
}

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { MiiGender, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import { z } from "zod";
@ -8,7 +8,8 @@ import { querySchema } from "@/lib/schemas";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import FilterSelect from "./filter-select";
import GenderSelect from "./gender-select";
import TagFilter from "./tag-filter";
import SortSelect from "./sort-select";
import Carousel from "../carousel";
import LikeButton from "../like-button";
@ -33,6 +34,7 @@ const searchSchema = z.object({
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
@ -54,8 +56,9 @@ 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, page = 1, limit = 24 } = parsed.data;
const { q: query, sort, tags, gender, page = 1, limit = 24 } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
if (inLikesPage && session?.user.id) {
@ -75,6 +78,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Profiles
...(userId && { userId }),
};
@ -136,8 +141,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
return (
<div className="w-full">
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
<p className="text-lg">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
<p className="text-xl">
{totalCount == filteredCount ? (
<>
<span className="font-extrabold">{totalCount}</span> Miis
@ -149,8 +154,9 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
)}
</p>
<div className="flex gap-2">
<FilterSelect />
<div className="flex items-center gap-2">
<GenderSelect />
<TagFilter />
<SortSelect />
</div>
</div>

View file

@ -1,4 +1,4 @@
import FilterSelect from "./filter-select";
import FilterSelect from "./tag-filter";
import SortSelect from "./sort-select";
import Pagination from "./pagination";

View file

@ -1,15 +1,18 @@
"use client";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useSelect } from "downshift";
import { redirect, useSearchParams } from "next/navigation";
import { Icon } from "@iconify/react";
type Sort = "newest" | "likes" | "oldest";
const items = ["newest", "likes", "oldest"];
export default function SortSelect() {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const currentSort = (searchParams.get("sort") as Sort) || "newest";
@ -21,12 +24,15 @@ export default function SortSelect() {
const params = new URLSearchParams(searchParams);
params.set("sort", selectedItem);
redirect(`?${params.toString()}`);
startTransition(() => {
router.push(`?${params.toString()}`);
});
},
});
return (
<div className="relative w-full">
<div className="relative w-fit">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap">
<span>Sort by </span>

View file

@ -0,0 +1,50 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../tag-selector";
export default function TagFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const rawTags = searchParams.get("tags") || "";
const preexistingTags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags]
);
const [tags, setTags] = useState<string[]>(preexistingTags);
// Redirect automatically on tags change
useEffect(() => {
const urlTags = preexistingTags.join(",");
const stateTags = tags.join(",");
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
if (tags.length > 0) {
params.set("tags", stateTags);
} else {
params.delete("tags");
}
startTransition(() => {
router.push(`?${params.toString()}`);
});
}, [tags, preexistingTags, searchParams, router]);
return (
<div className="w-72">
<TagSelector tags={tags} setTags={setTags} />
</div>
);
}

View file

@ -91,7 +91,7 @@ export default function TagSelector({ tags, setTags }: Props) {
{...getInputProps({
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select an item...",
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>