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 { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { Suspense } from "react";
|
||||||
import MiiList from "@/components/mii-list";
|
|
||||||
|
|
||||||
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();
|
const session = await auth();
|
||||||
|
|
||||||
if (session?.user && !session.user.username) {
|
if (session?.user && !session.user.username) {
|
||||||
redirect("/create-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 { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
import ProfileInformation from "@/components/profile-information";
|
import ProfileInformation from "@/components/profile-information";
|
||||||
import MiiList from "@/components/mii-list";
|
import MiiList from "@/components/mii-list";
|
||||||
|
import Skeleton from "@/components/mii-list/skeleton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,8 +65,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProfilePage({ params }: Props) {
|
export default async function ProfilePage({ searchParams, params }: Props) {
|
||||||
const session = await auth();
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|
@ -78,7 +79,9 @@ export default async function ProfilePage({ params }: Props) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ProfileInformation userId={user.id} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { Icon } from "@iconify/react";
|
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 FilterSelect from "./filter-select";
|
||||||
import SortSelect from "./sort-select";
|
import SortSelect from "./sort-select";
|
||||||
import Carousel from "../carousel";
|
import Carousel from "../carousel";
|
||||||
|
|
@ -15,54 +16,114 @@ import DeleteMiiButton from "../delete-mii";
|
||||||
import Pagination from "./pagination";
|
import Pagination from "./pagination";
|
||||||
|
|
||||||
interface Props {
|
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
|
// Profiles
|
||||||
userId?: number;
|
...(userId && { userId }),
|
||||||
sessionUserId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 select: Prisma.MiiSelect = {
|
||||||
const searchParams = useSearchParams();
|
id: true,
|
||||||
const { data, error } = useSWR<ApiResponse>(`/api/mii/list?${searchParams.toString()}${userId ? `&userId=${userId}` : ""}`, fetcher);
|
// 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 (
|
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">
|
||||||
{data ? (
|
{totalCount == filteredCount ? (
|
||||||
data.total == data.filtered ? (
|
|
||||||
<>
|
<>
|
||||||
<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">{filteredCount}</span> of <span className="font-extrabold">{totalCount}</span> Miis
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="font-extrabold">0</span> Miis
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -73,10 +134,8 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<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"
|
||||||
|
|
@ -102,7 +161,7 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: 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.isLiked} isLoggedIn={isLoggedIn} abbreviate />
|
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate />
|
||||||
|
|
||||||
{!userId && (
|
{!userId && (
|
||||||
<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">
|
||||||
|
|
@ -110,7 +169,7 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userId && sessionUserId == userId && (
|
{userId && Number(session?.user.id) == userId && (
|
||||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||||
<Link href={`/edit/${mii.id}`} title="Edit Mii" data-tooltip="Edit">
|
<Link href={`/edit/${mii.id}`} title="Edit Mii" data-tooltip="Edit">
|
||||||
<Icon icon="mdi:pencil" />
|
<Icon icon="mdi:pencil" />
|
||||||
|
|
@ -123,21 +182,8 @@ export default function MiiList({ isLoggedIn, userId, sessionUserId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
|
import FilterSelect from "./filter-select";
|
||||||
|
import SortSelect from "./sort-select";
|
||||||
|
import Pagination from "./pagination";
|
||||||
|
|
||||||
export default function Skeleton() {
|
export default function Skeleton() {
|
||||||
return (
|
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 */}
|
{/* Carousel Skeleton */}
|
||||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||||
<div className="aspect-[3/2]"></div>
|
<div className="aspect-[3/2]"></div>
|
||||||
|
|
@ -24,5 +42,12 @@ export default function Skeleton() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none">
|
||||||
|
<Pagination lastPage={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue