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 { 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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue