diff --git a/src/app/api/mii/list/route.ts b/src/app/api/mii/list/route.ts index a78d2ca..cbe477f 100644 --- a/src/app/api/mii/list/route.ts +++ b/src/app/api/mii/list/route.ts @@ -4,10 +4,10 @@ import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { nameSchema } from "@/lib/schemas"; +import { querySchema } from "@/lib/schemas"; const searchSchema = z.object({ - query: nameSchema.optional(), + q: querySchema.optional(), sort: z.enum(["newest", "likes"], { message: "Sort must be either 'newest' or 'likes'" }).default("newest"), tags: z .string() diff --git a/src/app/components/mii-list/index.tsx b/src/app/components/mii-list/index.tsx index e0715c2..8e5cd4f 100644 --- a/src/app/components/mii-list/index.tsx +++ b/src/app/components/mii-list/index.tsx @@ -1,8 +1,8 @@ -import Link from "next/link"; -import { Prisma } from "@prisma/client"; +"use client"; -import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useEffect, useState } from "react"; import SortSelect from "./sort-select"; import Carousel from "../carousel"; @@ -10,99 +10,70 @@ import LikeButton from "../like-button"; import FilterSelect from "./filter-select"; interface Props { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - // for use on profiles + isLoggedIn: boolean; + // Profiles userId?: number; - where?: Record; } -export default async function MiiList({ searchParams, userId, where }: Props) { - const session = await auth(); - const resolvedSearchParams = await searchParams; +interface ApiResponse { + total: number; + filtered: number; + miis: { + id: number; + user?: { + id: number; + username: string; + }; + name: string; + imageCount: number; + tags: string[]; + createdAt: string; + likes: number; + isLiked: boolean; + }[]; +} - // Sort search param - // Defaults to newest - const orderBy: Prisma.MiiOrderByWithRelationInput = - resolvedSearchParams.sort === "newest" - ? { createdAt: "desc" } - : resolvedSearchParams.sort === "likes" - ? { likedBy: { _count: "desc" } } - : { createdAt: "desc" }; +export default function MiiList({ isLoggedIn, userId }: Props) { + const searchParams = useSearchParams(); - // Tag search param - const rawTags = resolvedSearchParams.tags; - const tagFilter = - typeof rawTags === "string" - ? rawTags - .split(",") - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0) - : []; - const whereTags = tagFilter.length > 0 ? { tags: { hasEvery: tagFilter } } : undefined; + const [data, setData] = useState(); + const [error, setError] = useState(); - const userInclude = - userId == null - ? { - user: { - select: { - id: true, - username: true, - }, - }, - } - : {}; + const getData = async () => { + const response = await fetch(`/api/mii/list?${searchParams.toString()}`); + const data = await response.json(); - const totalMiiCount = await prisma.mii.count({ where: { userId } }); - const shownMiiCount = await prisma.mii.count({ - where: { - ...whereTags, - ...where, - userId, - }, - }); + if (!response.ok) { + setError(data.error); + return; + } - const miis = await prisma.mii.findMany({ - where: { - ...whereTags, - ...where, - userId, - }, - orderBy, - include: { - ...userInclude, - likedBy: { - where: userId - ? { - userId: Number(session?.user.id), - } - : {}, - select: { - userId: true, - }, - }, - _count: { - select: { likedBy: true }, - }, - }, - }); + setData(data); + }; - const formattedMiis = miis.map((mii) => ({ - ...mii, - likes: mii._count.likedBy, - isLikedByUser: mii.likedBy.length > 0, // True if the user has liked the Mii - })); + useEffect(() => { + getData(); + }, [searchParams.toString()]); + + // todo: show skeleton when data is undefined return (

- {totalMiiCount == shownMiiCount ? ( - <> - {totalMiiCount} Miis - + {data ? ( + data.total == data.filtered ? ( + <> + {data.total} Miis + + ) : ( + <> + {data.filtered} of {data.total} Miis + + ) ) : ( <> - {shownMiiCount} of {totalMiiCount} Miis + 0 Miis )}

@@ -113,48 +84,52 @@ export default async function MiiList({ searchParams, userId, where }: Props) {
- {miis.length > 0 ? ( -
- {formattedMiis.map((mii) => ( -
- `/mii/${mii.id}/image${index}.webp`), - ]} - /> + {data ? ( + data.miis.length > 0 ? ( +
+ {data.miis.map((mii) => ( +
+ `/mii/${mii.id}/image${index}.webp`), + ]} + /> -
- - {mii.name} - -
- {mii.tags.map((tag) => ( - - {tag} - - ))} -
+
+ + {mii.name} + +
+ {mii.tags.map((tag) => ( + + {tag} + + ))} +
-
- +
+ - {userId == null && ( - - @{mii.user?.username} - - )} + {userId == null && ( + + @{mii.user?.username} + + )} +
-
- ))} -
+ ))} +
+ ) : ( +

No results found.

+ ) ) : ( -

No results found.

+ <>{error &&

Error: {error}

} )}
); diff --git a/src/app/components/search-bar.tsx b/src/app/components/search-bar.tsx index 995551a..e20df82 100644 --- a/src/app/components/search-bar.tsx +++ b/src/app/components/search-bar.tsx @@ -1,18 +1,23 @@ "use client"; +import { redirect, useSearchParams } from "next/navigation"; import { useState } from "react"; import { Icon } from "@iconify/react"; -import { redirect } from "next/navigation"; -import { nameSchema } from "@/lib/schemas"; +import { querySchema } from "@/lib/schemas"; export default function SearchBar() { + const searchParams = useSearchParams(); const [query, setQuery] = useState(""); const handleSearch = () => { - const result = nameSchema.safeParse(query); + const result = querySchema.safeParse(query); if (!result.success) redirect("/"); - redirect(`/search?q=${query}`); + // Clone current search params and add query param + const params = new URLSearchParams(searchParams.toString()); + params.set("q", query); + + redirect(`/?${params.toString()}`); }; const handleKeyDown = (event: React.KeyboardEvent) => { diff --git a/src/app/page.tsx b/src/app/page.tsx index e729576..17960ce 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,12 +2,12 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import MiiList from "./components/mii-list"; -export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { +export default async function Page() { const session = await auth(); if (session?.user && !session.user.username) { redirect("/create-username"); } - return ; + return ; } diff --git a/src/app/profile/[slug]/page.tsx b/src/app/profile/[slug]/page.tsx index 23fd859..78a3e5c 100644 --- a/src/app/profile/[slug]/page.tsx +++ b/src/app/profile/[slug]/page.tsx @@ -11,10 +11,9 @@ import MiiList from "@/app/components/mii-list"; interface Props { params: Promise<{ slug: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } -export default async function ProfilePage({ params, searchParams }: Props) { +export default async function ProfilePage({ params }: Props) { const session = await auth(); const { slug } = await params; @@ -59,7 +58,7 @@ export default async function ProfilePage({ params, searchParams }: Props) {
- + ); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx deleted file mode 100644 index 23c12b9..0000000 --- a/src/app/search/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { notFound } from "next/navigation"; -import { z } from "zod"; - -import MiiList from "../components/mii-list"; - -interface Props { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -} - -const searchSchema = z - .string() - .trim() - .min(2) - .max(64) - .regex(/^[a-zA-Z0-9_]+$/); - -export default async function SearchPage({ searchParams }: Props) { - const { q: rawQuery } = await searchParams; - - const result = searchSchema.safeParse(rawQuery); - if (!result.success) notFound(); - - const query = result.data.toLowerCase(); - - return ( -
-

- Search results for "{query}" -

- -
- ); -} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 57d8c03..469f0c6 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -9,6 +9,15 @@ export const nameSchema = z message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", }); +export const querySchema = z + .string() + .trim() + .min(2, { message: "Search query must be at least 2 characters long" }) + .max(64, { message: "Search query cannot be more than 64 characters long" }) + .regex(/^[a-zA-Z0-9-_. ']+$/, { + message: "Search query can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", + }); + export const tagsSchema = z .array( z