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:
trafficlunar 2025-05-10 19:38:40 +01:00
parent c4a8e82313
commit 5a01bfb234
5 changed files with 215 additions and 245 deletions

View file

@ -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,
})),
});
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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<ApiResponse>(`/api/mii/list?${searchParams.toString()}${userId ? `&userId=${userId}` : ""}`, fetcher);
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 && { 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 (
<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 ? (
<>
<span className="font-extrabold">{data.total}</span> Miis
</>
) : (
<>
<span className="font-extrabold">{data.filtered}</span> of <span className="font-extrabold">{data.total}</span> Miis
</>
)
{totalCount == filteredCount ? (
<>
<span className="font-extrabold">{totalCount}</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,71 +134,56 @@ 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) => (
<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}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{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}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}>
{mii.name}
<div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}>
{mii.name}
</Link>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</Link>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</Link>
))}
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={isLoggedIn} abbreviate />
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username}
</Link>
)}
{userId && sessionUserId == 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" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
</div>
)}
</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} />}
<div className="mt-auto grid grid-cols-2 items-center">
<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">
@{mii.user?.username}
</Link>
)}
{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" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
</div>
)}
</div>
</div>
</div>
))}
</div>
<Pagination lastPage={lastPage} />
</div>
);
}

View file

@ -1,27 +1,52 @@
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">
{/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-[3/2]"></div>
<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>
{/* Content */}
<div className="p-4 flex flex-col gap-1 h-full">
{/* Name */}
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
<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>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1">
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
</div>
{/* Content */}
<div className="p-4 flex flex-col gap-1 h-full">
{/* Name */}
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
{/* Bottom row */}
<div className="mt-0.5 grid grid-cols-2 items-center">
<div className="h-6 w-12 bg-red-200 rounded" />
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1">
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
</div>
{/* Bottom row */}
<div className="mt-0.5 grid grid-cols-2 items-center">
<div className="h-6 w-12 bg-red-200 rounded" />
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
</div>
</div>
</div>
))}
</div>
<div className="pointer-events-none">
<Pagination lastPage={10} />
</div>
</div>
);