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 { 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()

View file

@ -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<string, object>;
}
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<ApiResponse>();
const [error, setError] = useState<string | undefined>();
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 (
<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">
{totalMiiCount == shownMiiCount ? (
<>
<span className="font-extrabold">{totalMiiCount}</span> Miis
</>
{data ? (
data.total == data.filtered ? (
<>
<span className="font-extrabold">{data.total}</span> Miis
</>
) : (
<>
<span className="font-extrabold">{data.filtered}</span> of <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">0</span> Miis
</>
)}
</p>
@ -113,48 +84,52 @@ export default async function MiiList({ searchParams, userId, where }: Props) {
</div>
</div>
{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">
{formattedMiis.map((mii) => (
<div
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"
>
<Carousel
images={[
`/mii/${mii.id}/mii.webp`,
`/mii/${mii.id}/qr-code.webp`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image${index}.webp`),
]}
/>
{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">
{data.miis.map((mii) => (
<div
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"
>
<Carousel
images={[
`/mii/${mii.id}/mii.webp`,
`/mii/${mii.id}/qr-code.webp`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image${index}.webp`),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl overflow-hidden text-ellipsis line-clamp-2" title={mii.name}>
{mii.name}
</Link>
<div id="tags" className="flex gap-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag}>
{tag}
</Link>
))}
</div>
<div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl overflow-hidden text-ellipsis line-clamp-2" title={mii.name}>
{mii.name}
</Link>
<div id="tags" className="flex gap-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag}>
{tag}
</Link>
))}
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLikedByUser} isLoggedIn={session?.user != null} />
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={isLoggedIn} />
{userId == null && (
<Link href={`/profile/${mii.user.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username}
</Link>
)}
{userId == null && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username}
</Link>
)}
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
) : (
<p className="text-xl font-semibold text-center mt-10">No results found.</p>
)
) : (
<p className="text-xl 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>
);

View file

@ -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) => {

View file

@ -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 <MiiList searchParams={searchParams} />;
return <MiiList isLoggedIn={session?.user != null} />;
}

View file

@ -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) {
</div>
</div>
<MiiList searchParams={searchParams} userId={user?.id} />
<MiiList isLoggedIn={session?.user != null} userId={user?.id} />
</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.",
});
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