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:
parent
1a14683a10
commit
e1a158d070
11 changed files with 141 additions and 93 deletions
|
|
@ -17,7 +17,6 @@
|
||||||
"@bprogress/next": "^3.2.12",
|
"@bprogress/next": "^3.2.12",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/client": "^6.11.1",
|
"@prisma/client": "^6.11.1",
|
||||||
"@types/sjcl": "^1.0.34",
|
|
||||||
"bit-buffer": "^0.2.5",
|
"bit-buffer": "^0.2.5",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
|
@ -47,6 +46,7 @@
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/sjcl": "^1.0.34",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"eslint-config-next": "15.3.5",
|
"eslint-config-next": "15.3.5",
|
||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,6 @@ importers:
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.11.1
|
specifier: ^6.11.1
|
||||||
version: 6.11.1(prisma@6.11.1(typescript@5.8.3))(typescript@5.8.3)
|
version: 6.11.1(prisma@6.11.1(typescript@5.8.3))(typescript@5.8.3)
|
||||||
'@types/sjcl':
|
|
||||||
specifier: ^1.0.34
|
|
||||||
version: 1.0.34
|
|
||||||
bit-buffer:
|
bit-buffer:
|
||||||
specifier: ^0.2.5
|
specifier: ^0.2.5
|
||||||
version: 0.2.5
|
version: 0.2.5
|
||||||
|
|
@ -108,6 +105,9 @@ importers:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.1.6
|
specifier: ^19.1.6
|
||||||
version: 19.1.6(@types/react@19.1.8)
|
version: 19.1.6(@types/react@19.1.8)
|
||||||
|
'@types/sjcl':
|
||||||
|
specifier: ^1.0.34
|
||||||
|
version: 1.0.34
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.31.0
|
specifier: ^9.31.0
|
||||||
version: 9.31.0(jiti@2.4.2)
|
version: 9.31.0(jiti@2.4.2)
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,9 @@ export default async function ProfilePage({ searchParams, params }: Props) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ProfileInformation userId={user.id} />
|
<ProfileInformation userId={user.id} />
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
|
<Suspense fallback={<Skeleton />}>
|
||||||
<Suspense fallback={<Skeleton />}>
|
<MiiList searchParams={await searchParams} userId={user.id} />
|
||||||
<MiiList searchParams={await searchParams} userId={user.id} />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,16 @@ export default async function ProfileSettingsPage({ searchParams }: Props) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ProfileInformation page="likes" />
|
<ProfileInformation page="likes" />
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">My Likes</h2>
|
<h2 className="text-2xl font-bold">My Likes</h2>
|
||||||
<p className="text-sm text-zinc-500">View every Mii you have liked on TomodachiShare.</p>
|
<p className="text-sm text-zinc-500">View every Mii you have liked on TomodachiShare.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-5 flex items-center">
|
|
||||||
<hr className="flex-grow border-zinc-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<Skeleton />}>
|
|
||||||
<MiiList inLikesPage searchParams={await searchParams} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<MiiList inLikesPage searchParams={await searchParams} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
54
src/components/mii-list/gender-select.tsx
Normal file
54
src/components/mii-list/gender-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { MiiGender, Prisma } from "@prisma/client";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { querySchema } from "@/lib/schemas";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 SortSelect from "./sort-select";
|
||||||
import Carousel from "../carousel";
|
import Carousel from "../carousel";
|
||||||
import LikeButton from "../like-button";
|
import LikeButton from "../like-button";
|
||||||
|
|
@ -33,6 +34,7 @@ const searchSchema = z.object({
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter((tag) => tag.length > 0)
|
.filter((tag) => tag.length > 0)
|
||||||
),
|
),
|
||||||
|
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
||||||
// todo: incorporate tagsSchema
|
// todo: incorporate tagsSchema
|
||||||
// Pages
|
// Pages
|
||||||
limit: z.coerce
|
limit: z.coerce
|
||||||
|
|
@ -54,8 +56,9 @@ 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, 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;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
||||||
if (inLikesPage && session?.user.id) {
|
if (inLikesPage && session?.user.id) {
|
||||||
|
|
@ -75,6 +78,8 @@ 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 } }),
|
||||||
|
// Gender
|
||||||
|
...(gender && { gender: { equals: gender } }),
|
||||||
// Profiles
|
// Profiles
|
||||||
...(userId && { userId }),
|
...(userId && { userId }),
|
||||||
};
|
};
|
||||||
|
|
@ -136,8 +141,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
<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-lg">
|
<p className="text-xl">
|
||||||
{totalCount == filteredCount ? (
|
{totalCount == filteredCount ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-extrabold">{totalCount}</span> Miis
|
<span className="font-extrabold">{totalCount}</span> Miis
|
||||||
|
|
@ -149,8 +154,9 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FilterSelect />
|
<GenderSelect />
|
||||||
|
<TagFilter />
|
||||||
<SortSelect />
|
<SortSelect />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import FilterSelect from "./filter-select";
|
import FilterSelect from "./tag-filter";
|
||||||
import SortSelect from "./sort-select";
|
import SortSelect from "./sort-select";
|
||||||
import Pagination from "./pagination";
|
import Pagination from "./pagination";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Icon } from "@iconify/react";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
import { useSelect } from "downshift";
|
import { useSelect } from "downshift";
|
||||||
import { redirect, useSearchParams } from "next/navigation";
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
type Sort = "newest" | "likes" | "oldest";
|
type Sort = "newest" | "likes" | "oldest";
|
||||||
|
|
||||||
const items = ["newest", "likes", "oldest"];
|
const items = ["newest", "likes", "oldest"];
|
||||||
|
|
||||||
export default function SortSelect() {
|
export default function SortSelect() {
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||||
|
|
||||||
|
|
@ -21,12 +24,15 @@ export default function SortSelect() {
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("sort", selectedItem);
|
params.set("sort", selectedItem);
|
||||||
redirect(`?${params.toString()}`);
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-fit">
|
||||||
{/* Toggle button to open the dropdown */}
|
{/* 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">
|
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap">
|
||||||
<span>Sort by </span>
|
<span>Sort by </span>
|
||||||
|
|
|
||||||
50
src/components/mii-list/tag-filter.tsx
Normal file
50
src/components/mii-list/tag-filter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -91,7 +91,7 @@ export default function TagSelector({ tags, setTags }: Props) {
|
||||||
{...getInputProps({
|
{...getInputProps({
|
||||||
onKeyDown: handleKeyDown,
|
onKeyDown: handleKeyDown,
|
||||||
disabled: isMaxItemsSelected,
|
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",
|
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue