diff --git a/src/app/api/mii/list/route.ts b/src/app/api/mii/list/route.ts deleted file mode 100644 index 04837be..0000000 --- a/src/app/api/mii/list/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextRequest } from "next/server"; -import { Prisma } from "@prisma/client"; -import { z } from "zod"; - -import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { querySchema } from "@/lib/schemas"; -import { RateLimit } from "@/lib/rate-limit"; - -const searchSchema = z.object({ - q: querySchema.optional(), - sort: z.enum(["newest", "likes"], { message: "Sort must be either 'newest' or 'likes'" }).default("newest"), - tags: z - .string() - .optional() - .transform((value) => - value - ?.split(",") - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0) - ), - // todo: incorporate tagsSchema - // Profiles - userId: z.coerce - .number({ message: "User ID must be a number" }) - .int({ message: "User ID must be an integer" }) - .positive({ message: "User ID must be valid" }) - .optional(), - // Pages - limit: z.coerce - .number({ message: "Limit must be a number" }) - .int({ message: "Limit must be an integer" }) - .min(1, { message: "Limit must be at least 1" }) - .max(100, { message: "Limit cannot be more than 100" }) - .optional(), - page: z.coerce - .number({ message: "Page must be a number" }) - .int({ message: "Page must be an integer" }) - .min(1, { message: "Page must be at least 1" }) - .optional(), -}); - -export async function GET(request: NextRequest) { - const session = await auth(); - - const rateLimit = new RateLimit(request, 30); - const check = await rateLimit.handle(); - if (check) return check; - - const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); - if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400); - - const { q: query, sort, tags, userId, page = 1, limit = 24 } = parsed.data; - - const where: Prisma.MiiWhereInput = { - // Searching - ...(query && { - OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }], - }), - // Tag filtering - ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), - // Profiles - ...(userId && { userId }), - }; - - // Sorting by likes or newest - const orderBy: Prisma.MiiOrderByWithRelationInput[] = - sort === "likes" ? [{ likedBy: { _count: "desc" } }, { name: "asc" }] : [{ createdAt: "desc" }, { name: "asc" }]; - - const select: Prisma.MiiSelect = { - id: true, - // Don't show when userId is specified - ...(!userId && { - user: { - select: { - id: true, - username: true, - }, - }, - }), - name: true, - imageCount: true, - tags: true, - createdAt: true, - // Mii liked check - ...(session?.user?.id && { - likedBy: { - where: { userId: Number(session.user.id) }, - select: { userId: true }, - }, - }), - // Like count - _count: { - select: { likedBy: true }, - }, - }; - - const skip = (page - 1) * limit; - - const [totalCount, filteredCount, list] = await Promise.all([ - prisma.mii.count({ where: { ...where, userId } }), - prisma.mii.count({ where, skip, take: limit }), - prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), - ]); - - return rateLimit.sendResponse({ - total: totalCount, - filtered: filteredCount, - lastPage: Math.ceil(totalCount / limit), - miis: list.map(({ _count, likedBy, ...rest }) => ({ - ...rest, - likes: _count.likedBy, - isLiked: session?.user?.id ? likedBy.length > 0 : false, - })), - }); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 45a32a7..fca60e7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,25 @@ import { redirect } from "next/navigation"; -import { auth } from "@/lib/auth"; -import MiiList from "@/components/mii-list"; +import { Suspense } from "react"; -export default async function Page() { +import { auth } from "@/lib/auth"; + +import MiiList from "@/components/mii-list"; +import Skeleton from "@/components/mii-list/skeleton"; + +interface Props { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function Page({ searchParams }: Props) { const session = await auth(); if (session?.user && !session.user.username) { redirect("/create-username"); } - return ; + return ( + }> + + + ); } diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 1fb8ba3..8eff014 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -1,13 +1,15 @@ import { Metadata } from "next"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; -import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import ProfileInformation from "@/components/profile-information"; import MiiList from "@/components/mii-list"; +import Skeleton from "@/components/mii-list/skeleton"; interface Props { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; params: Promise<{ id: string }>; } @@ -63,8 +65,7 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function ProfilePage({ params }: Props) { - const session = await auth(); +export default async function ProfilePage({ searchParams, params }: Props) { const { id } = await params; const user = await prisma.user.findUnique({ @@ -78,7 +79,9 @@ export default async function ProfilePage({ params }: Props) { return (
- + }> + +
); } diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 03df3f9..4aa38f9 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii-list/index.tsx @@ -1,12 +1,13 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; import Link from "next/link"; -import useSWR from "swr"; +import { Prisma } from "@prisma/client"; import { Icon } from "@iconify/react"; +import { z } from "zod"; + +import { querySchema } from "@/lib/schemas"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; -import Skeleton from "./skeleton"; import FilterSelect from "./filter-select"; import SortSelect from "./sort-select"; import Carousel from "../carousel"; @@ -15,54 +16,114 @@ import DeleteMiiButton from "../delete-mii"; import Pagination from "./pagination"; interface Props { - isLoggedIn: boolean; - // Profiles - userId?: number; - sessionUserId?: number; + searchParams: { [key: string]: string | string[] | undefined }; + userId?: number; // Profiles } -interface ApiResponse { - total: number; - filtered: number; - lastPage: number; - miis: { - id: number; - user?: { - id: number; - username: string; - }; - name: string; - imageCount: number; - tags: string[]; - createdAt: string; - likes: number; - isLiked: boolean; - }[]; -} +const searchSchema = z.object({ + q: querySchema.optional(), + sort: z.enum(["newest", "likes"], { message: "Sort must be either 'newest' or 'likes'" }).default("newest"), + tags: z + .string() + .optional() + .transform((value) => + value + ?.split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + ), + // todo: incorporate tagsSchema + // Pages + limit: z.coerce + .number({ message: "Limit must be a number" }) + .int({ message: "Limit must be an integer" }) + .min(1, { message: "Limit must be at least 1" }) + .max(100, { message: "Limit cannot be more than 100" }) + .optional(), + page: z.coerce + .number({ message: "Page must be a number" }) + .int({ message: "Page must be an integer" }) + .min(1, { message: "Page must be at least 1" }) + .optional(), +}); -const fetcher = (url: string) => fetch(url).then((res) => res.json()); +export default async function MiiList({ searchParams, userId }: Props) { + const session = await auth(); -export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) { - const searchParams = useSearchParams(); - const { data, error } = useSWR(`/api/mii/list?${searchParams.toString()}${userId ? `&userId=${userId}` : ""}`, fetcher); + const parsed = searchSchema.safeParse(searchParams); + if (!parsed.success) return

{parsed.error.errors[0].message}

; + + const { q: query, sort, tags, page = 1, limit = 24 } = parsed.data; + + const where: Prisma.MiiWhereInput = { + // Searching + ...(query && { + OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }], + }), + // Tag filtering + ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), + // Profiles + ...(userId && { userId }), + }; + + // Sorting by likes or newest + const orderBy: Prisma.MiiOrderByWithRelationInput[] = + sort === "likes" ? [{ likedBy: { _count: "desc" } }, { name: "asc" }] : [{ createdAt: "desc" }, { name: "asc" }]; + + const select: Prisma.MiiSelect = { + id: true, + // Don't show when userId is specified + ...(!userId && { + user: { + select: { + id: true, + username: true, + }, + }, + }), + name: true, + imageCount: true, + tags: true, + createdAt: true, + // Mii liked check + ...(session?.user?.id && { + likedBy: { + where: { userId: Number(session.user.id) }, + select: { userId: true }, + }, + }), + // Like count + _count: { + select: { likedBy: true }, + }, + }; + + const skip = (page - 1) * limit; + + const [totalCount, filteredCount, list] = await Promise.all([ + prisma.mii.count({ where: { ...where, userId } }), + prisma.mii.count({ where, skip, take: limit }), + prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), + ]); + + const lastPage = Math.ceil(totalCount / limit); + const miis = list.map(({ _count, likedBy, ...rest }) => ({ + ...rest, + likes: _count.likedBy, + isLiked: session?.user?.id ? likedBy.length > 0 : false, + })); return (

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

@@ -73,71 +134,56 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
- {data ? ( - data.miis.length > 0 ? ( -
- {data.miis.map((mii) => ( -
- `/mii/${mii.id}/image?type=image${index}`), - ]} - /> +
+ {miis.map((mii) => ( +
+ `/mii/${mii.id}/image?type=image${index}`), + ]} + /> -
- - {mii.name} +
+ + {mii.name} + +
+ {mii.tags.map((tag) => ( + + {tag} -
- {mii.tags.map((tag) => ( - - {tag} - - ))} -
- -
- - - {!userId && ( - - @{mii.user?.username} - - )} - - {userId && sessionUserId == userId && ( -
- - - - -
- )} -
-
+ ))}
- ))} -
- ) : ( -

No results found.

- ) - ) : error ? ( -

Error: {error}

- ) : ( - // Show skeleton when data is loading -
- {Array.from({ length: 24 }).map((_, i) => ( - - ))} -
- )} - {data && } +
+ + + {!userId && ( + + @{mii.user?.username} + + )} + + {userId && Number(session?.user.id) == userId && ( +
+ + + + +
+ )} +
+
+
+ ))} +
+ +
); } diff --git a/src/components/mii-list/skeleton.tsx b/src/components/mii-list/skeleton.tsx index 1af63ae..9747ecc 100644 --- a/src/components/mii-list/skeleton.tsx +++ b/src/components/mii-list/skeleton.tsx @@ -1,27 +1,52 @@ +import FilterSelect from "./filter-select"; +import SortSelect from "./sort-select"; +import Pagination from "./pagination"; + export default function Skeleton() { return ( -
- {/* Carousel Skeleton */} -
-
+
+
+

+ ??? Miis +

+ +
+ + +
- {/* Content */} -
- {/* Name */} -
+
+ {[...Array(24)].map((_, index) => ( +
+ {/* Carousel Skeleton */} +
+
+
- {/* Tags */} -
-
-
-
+ {/* Content */} +
+ {/* Name */} +
- {/* Bottom row */} -
-
-
-
+ {/* Tags */} +
+
+
+
+ + {/* Bottom row */} +
+
+
+
+
+
+ ))} +
+ +
+
);