feat: port mii list to use api route

also make search bar redirect to index page with search param instead of
a new page
This commit is contained in:
trafficlunar 2025-04-12 23:13:34 +01:00
parent 90eca1bf3f
commit 53a23f35ef
7 changed files with 116 additions and 166 deletions

View file

@ -4,10 +4,10 @@ import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { nameSchema } from "@/lib/schemas"; import { querySchema } from "@/lib/schemas";
const searchSchema = z.object({ 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"), sort: z.enum(["newest", "likes"], { message: "Sort must be either 'newest' or 'likes'" }).default("newest"),
tags: z tags: z
.string() .string()

View file

@ -1,8 +1,8 @@
import Link from "next/link"; "use client";
import { Prisma } from "@prisma/client";
import { auth } from "@/lib/auth"; import { useSearchParams } from "next/navigation";
import { prisma } from "@/lib/prisma"; import Link from "next/link";
import { useEffect, useState } from "react";
import SortSelect from "./sort-select"; import SortSelect from "./sort-select";
import Carousel from "../carousel"; import Carousel from "../carousel";
@ -10,99 +10,70 @@ import LikeButton from "../like-button";
import FilterSelect from "./filter-select"; import FilterSelect from "./filter-select";
interface Props { interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; isLoggedIn: boolean;
// for use on profiles // Profiles
userId?: number; userId?: number;
where?: Record<string, object>;
} }
export default async function MiiList({ searchParams, userId, where }: Props) { interface ApiResponse {
const session = await auth(); total: number;
const resolvedSearchParams = await searchParams; filtered: number;
miis: {
// Sort search param id: number;
// Defaults to newest user?: {
const orderBy: Prisma.MiiOrderByWithRelationInput = id: number;
resolvedSearchParams.sort === "newest" username: string;
? { createdAt: "desc" } };
: resolvedSearchParams.sort === "likes" name: string;
? { likedBy: { _count: "desc" } } imageCount: number;
: { createdAt: "desc" }; tags: string[];
createdAt: string;
// Tag search param likes: number;
const rawTags = resolvedSearchParams.tags; isLiked: boolean;
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 userInclude =
userId == null
? {
user: {
select: {
id: true,
username: true,
},
},
} }
: {};
const totalMiiCount = await prisma.mii.count({ where: { userId } }); export default function MiiList({ isLoggedIn, userId }: Props) {
const shownMiiCount = await prisma.mii.count({ const searchParams = useSearchParams();
where: {
...whereTags,
...where,
userId,
},
});
const miis = await prisma.mii.findMany({ const [data, setData] = useState<ApiResponse>();
where: { const [error, setError] = useState<string | undefined>();
...whereTags,
...where, const getData = async () => {
userId, const response = await fetch(`/api/mii/list?${searchParams.toString()}`);
}, const data = await response.json();
orderBy,
include: { if (!response.ok) {
...userInclude, setError(data.error);
likedBy: { return;
where: userId
? {
userId: Number(session?.user.id),
} }
: {},
select: {
userId: true,
},
},
_count: {
select: { likedBy: true },
},
},
});
const formattedMiis = miis.map((mii) => ({ setData(data);
...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 ( 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="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
<p className="text-lg"> <p className="text-lg">
{totalMiiCount == shownMiiCount ? ( {data ? (
data.total == data.filtered ? (
<> <>
<span className="font-extrabold">{totalMiiCount}</span> Miis <span className="font-extrabold">{data.total}</span> Miis
</> </>
) : ( ) : (
<> <>
<span className="font-extrabold">{shownMiiCount}</span> of <span className="font-extrabold">{totalMiiCount}</span> Miis <span className="font-extrabold">{data.filtered}</span> of <span className="font-extrabold">{data.total}</span> Miis
</>
)
) : (
<>
<span className="font-extrabold">0</span> Miis
</> </>
)} )}
</p> </p>
@ -113,9 +84,10 @@ export default async function MiiList({ searchParams, userId, where }: Props) {
</div> </div>
</div> </div>
{miis.length > 0 ? ( {data ? (
data.miis.length > 0 ? (
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2 max-[25rem]:grid-cols-1"> <div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2 max-[25rem]:grid-cols-1">
{formattedMiis.map((mii) => ( {data.miis.map((mii) => (
<div <div
key={mii.id} key={mii.id}
className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600" className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
@ -141,10 +113,10 @@ export default async function MiiList({ searchParams, userId, where }: Props) {
</div> </div>
<div className="mt-auto grid grid-cols-2 items-center"> <div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLikedByUser} isLoggedIn={session?.user != null} /> <LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={isLoggedIn} />
{userId == null && ( {userId == null && (
<Link href={`/profile/${mii.user.id}`} className="text-sm text-right overflow-hidden text-ellipsis"> <Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username} @{mii.user?.username}
</Link> </Link>
)} )}
@ -154,7 +126,10 @@ export default async function MiiList({ searchParams, userId, where }: Props) {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xl text-center mt-10">No results found.</p> <p className="text-xl font-semibold text-center mt-10">No results found.</p>
)
) : (
<>{error && <p className="text-xl text-red-400 font-semibold text-center mt-10">Error: {error}</p>}</>
)} )}
</div> </div>
); );

View file

@ -1,18 +1,23 @@
"use client"; "use client";
import { redirect, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { redirect } from "next/navigation"; import { querySchema } from "@/lib/schemas";
import { nameSchema } from "@/lib/schemas";
export default function SearchBar() { export default function SearchBar() {
const searchParams = useSearchParams();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const handleSearch = () => { const handleSearch = () => {
const result = nameSchema.safeParse(query); const result = querySchema.safeParse(query);
if (!result.success) redirect("/"); 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) => { const handleKeyDown = (event: React.KeyboardEvent) => {

View file

@ -2,12 +2,12 @@ import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import MiiList from "./components/mii-list"; 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(); const session = await auth();
if (session?.user && !session.user.username) { if (session?.user && !session.user.username) {
redirect("/create-username"); redirect("/create-username");
} }
return <MiiList searchParams={searchParams} />; return <MiiList isLoggedIn={session?.user != null} />;
} }

View file

@ -11,10 +11,9 @@ import MiiList from "@/app/components/mii-list";
interface Props { interface Props {
params: Promise<{ slug: string }>; 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 session = await auth();
const { slug } = await params; const { slug } = await params;
@ -59,7 +58,7 @@ export default async function ProfilePage({ params, searchParams }: Props) {
</div> </div>
</div> </div>
<MiiList searchParams={searchParams} userId={user?.id} /> <MiiList isLoggedIn={session?.user != null} userId={user?.id} />
</div> </div>
); );
} }

View file

@ -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 (
<div>
<p className="text-lg">
Search results for &quot;<span className="font-bold">{query}</span>&quot;
</p>
<MiiList
searchParams={searchParams}
where={{
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }],
}}
/>
</div>
);
}

View file

@ -9,6 +9,15 @@ export const nameSchema = z
message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", 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 export const tagsSchema = z
.array( .array(
z z