refactor: remove /api/mii/list
what is the point of next.js if i'm not even using it properly (should also hopefully improve SEO)
This commit is contained in:
parent
c4a8e82313
commit
5a01bfb234
5 changed files with 215 additions and 245 deletions
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -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 <MiiList isLoggedIn={session?.user != null} />;
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<MiiList searchParams={await searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
|||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<ProfileInformation userId={user.id} />
|
||||
<MiiList isLoggedIn={session?.user != null} userId={user.id} sessionUserId={Number(session?.user.id ?? -1)} />
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<MiiList searchParams={await searchParams} userId={user.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
userId?: number; // Profiles
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export default async function MiiList({ searchParams, userId }: Props) {
|
||||
const session = await auth();
|
||||
|
||||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.errors[0].message}</h1>;
|
||||
|
||||
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?: number;
|
||||
sessionUserId?: number;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
total: number;
|
||||
filtered: number;
|
||||
lastPage: number;
|
||||
miis: {
|
||||
id: number;
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
...(userId && { userId }),
|
||||
};
|
||||
name: string;
|
||||
imageCount: number;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
likes: number;
|
||||
isLiked: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
// Sorting by likes or newest
|
||||
const orderBy: Prisma.MiiOrderByWithRelationInput[] =
|
||||
sort === "likes" ? [{ likedBy: { _count: "desc" } }, { name: "asc" }] : [{ createdAt: "desc" }, { name: "asc" }];
|
||||
|
||||
export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
||||
const searchParams = useSearchParams();
|
||||
const { data, error } = useSWR<ApiResponse>(`/api/mii/list?${searchParams.toString()}${userId ? `&userId=${userId}` : ""}`, fetcher);
|
||||
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 (
|
||||
<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">
|
||||
{data ? (
|
||||
data.total == data.filtered ? (
|
||||
{totalCount == filteredCount ? (
|
||||
<>
|
||||
<span className="font-extrabold">{data.total}</span> Miis
|
||||
<span className="font-extrabold">{totalCount}</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
|
||||
<span className="font-extrabold">{filteredCount}</span> of <span className="font-extrabold">{totalCount}</span> Miis
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -73,10 +134,8 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
data.miis.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{data.miis.map((mii) => (
|
||||
{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"
|
||||
|
|
@ -102,7 +161,7 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
|||
</div>
|
||||
|
||||
<div className="mt-auto grid grid-cols-2 items-center">
|
||||
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={isLoggedIn} abbreviate />
|
||||
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate />
|
||||
|
||||
{!userId && (
|
||||
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
|
||||
|
|
@ -110,7 +169,7 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
|||
</Link>
|
||||
)}
|
||||
|
||||
{userId && sessionUserId == userId && (
|
||||
{userId && Number(session?.user.id) == userId && (
|
||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||
<Link href={`/edit/${mii.id}`} title="Edit Mii" data-tooltip="Edit">
|
||||
<Icon icon="mdi:pencil" />
|
||||
|
|
@ -123,21 +182,8 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
) : (
|
||||
// Show skeleton when data is loading
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && <Pagination lastPage={data.lastPage} />}
|
||||
<Pagination lastPage={lastPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
import FilterSelect from "./filter-select";
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 animate-pulse">
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
<span className="font-extrabold">???</span> Miis
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pointer-events-none">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{[...Array(24)].map((_, index) => (
|
||||
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-[3/2]"></div>
|
||||
|
|
@ -24,5 +42,12 @@ export default function Skeleton() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue