Compare commits

..

No commits in common. "1bf83e4cae88ad09def4b2b6e43afcad54d5350d" and "9f65847c3bbfb810aee9eaaae5038677bc3a0809" have entirely different histories.

9 changed files with 81 additions and 179 deletions

View file

@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
const check = await rateLimit.handle();
if (check) return check;
const idsParam = new URL(request.url).searchParams.get("ids");
if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
const ids = idsParam.split(",").map(Number).filter(Boolean);
if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
const liked = await prisma.like.findMany({
where: { userId: Number(session.user?.id), miiId: { in: ids } },
select: { miiId: true },
});
// Return only Miis that are liked
return NextResponse.json(liked.map((l) => l.miiId));
}

View file

@ -11,7 +11,7 @@ import { MiiPlatform } from "@prisma/client";
import LikeButton from "@/components/like-button";
import ImageViewer from "@/components/image-viewer";
import AuthorButtons from "@/components/mii/author-buttons";
import DeleteMiiButton from "@/components/mii/delete-mii-button";
import ShareMiiButton from "@/components/mii/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-add-mii";
@ -359,7 +359,15 @@ export default async function MiiPage({ params }: Props) {
{/* Buttons */}
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
<AuthorButtons mii={mii} />
{session && (Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) && (
<>
<Link aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
)}
<ShareMiiButton miiId={mii.id} />
<Link aria-label="Report Mii" href={`/report/mii/${mii.id}`}>

View file

@ -97,7 +97,7 @@ export default function ImageViewer({ src, alt, width, height, unoptimized = fal
type="button"
aria-label="Close"
onClick={close}
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
className={`pill button p-2! aspect-square text-2xl absolute top-4 right-4 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="material-symbols:close-rounded" />
</button>

View file

@ -56,10 +56,6 @@ export default function LikeButton({ likes, isLiked, miiId, disabled, abbreviate
loadIcons(["icon-park-solid:like", "icon-park-outline:like"]);
}, []);
useEffect(() => {
setIsLikedState(isLiked);
}, [isLiked]);
return (
<button
onClick={onClick}

View file

@ -1,37 +0,0 @@
"use client";
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { useSession } from "next-auth/react";
import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button";
interface Props {
mii: Prisma.MiiGetPayload<{
include: {
_count: {
select: {
likedBy: true;
};
};
};
}>;
}
export default function AuthorButtons({ mii }: Props) {
const session = useSession();
if (!session.data || Number(session.data.user?.id) !== mii.userId || Number(session.data.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))
return null;
return (
<>
<Link aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
);
}

View file

@ -21,7 +21,6 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [inputMiiName, setInputMiiName] = useState("");
const handleSubmit = async () => {
const response = await fetch(`/api/mii/${miiId}/delete`, { method: "DELETE" });
@ -86,7 +85,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex">
<Image src={`/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1" title={miiName}>
@ -96,21 +95,13 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
</div>
</div>
<p className="text-sm text-zinc-500 my-2">Type the Mii's name below to delete:</p>
<input type="text" className="pill input" value={inputMiiName} onChange={(e) => setInputMiiName(e.target.value)} />
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton
onClick={handleSubmit}
text="Delete"
disabled={inputMiiName != miiName}
className="bg-red-400! border-red-500! hover:bg-red-500! disabled:bg-red-200! disabled:border-red-300!"
/>
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div>
</div>
</div>,

View file

@ -16,7 +16,6 @@ import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button";
import Pagination from "./pagination";
import FilterMenu from "./filter-menu";
import MiiGrid from "./mii-grid";
interface Props {
searchParams: { [key: string]: string | string[] | undefined };
@ -103,7 +102,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") {
// Get all IDs that match the where conditions
@ -130,7 +129,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
// Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({
list = await prisma.mii.findMany({
where: {
id: { in: selectedIds },
},
@ -149,7 +148,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
[totalCount, filteredCount, miis] = await Promise.all([
[totalCount, filteredCount, list] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
@ -163,6 +162,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
}
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">
@ -189,7 +193,64 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
</div>
</div>
<MiiGrid miis={miis} />
<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 relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300" : "border-zinc-300"}`}
>
<Carousel
images={[
`/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between items-center">
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
{mii.name}
</Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
{mii.platform === "SWITCH" ? (
<Icon icon="cib:nintendo-switch" className="text-red-400" />
) : (
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
)}
</div>
</div>
<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} abbreviate />
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</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" aria-label="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,88 +0,0 @@
"use client";
import Link from "next/link";
import useSWR from "swr";
import { Prisma } from "@prisma/client";
import { useSession } from "next-auth/react";
import { Icon } from "@iconify/react";
import LikeButton from "@/components/like-button";
import DeleteMiiButton from "../delete-mii-button";
import Carousel from "@/components/carousel";
interface Props {
miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
userId?: number;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function MiiGrid({ miis, userId }: Props) {
const session = useSession();
const ids = miis.map((m) => m.id).join(",");
const { data } = useSWR<number[]>(session.data?.user && miis.length > 0 ? `/api/mii/has-liked?ids=${ids}` : null, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const likedIds = new Set(data ?? []);
return (
<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 relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300" : "border-zinc-300"}`}
>
<Carousel
images={[
`/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between items-center">
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
{mii.name}
</Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
{mii.platform === "SWITCH" ? (
<Icon icon="cib:nintendo-switch" className="text-red-400" />
) : (
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
)}
</div>
</div>
<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._count.likedBy} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</Link>
)}
{userId && Number(session.data?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}

View file

@ -5,12 +5,11 @@ import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
export default function SubmitButton({ onClick, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
@ -25,7 +24,7 @@ export default function SubmitButton({ onClick, disabled = false, text = "Submit
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
<button type="submit" aria-label={text} onClick={handleClick} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>