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:
parent
90eca1bf3f
commit
53a23f35ef
7 changed files with 116 additions and 166 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "<span className="font-bold">{query}</span>"
|
||||
</p>
|
||||
<MiiList
|
||||
searchParams={searchParams}
|
||||
where={{
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue