mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
3 commits
9f65847c3b
...
1bf83e4cae
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bf83e4cae | |||
| 3163fac2eb | |||
| 77266d0ef1 |
9 changed files with 179 additions and 81 deletions
28
src/app/api/mii/has-liked/route.ts
Normal file
28
src/app/api/mii/has-liked/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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));
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { MiiPlatform } from "@prisma/client";
|
|||
|
||||
import LikeButton from "@/components/like-button";
|
||||
import ImageViewer from "@/components/image-viewer";
|
||||
import DeleteMiiButton from "@/components/mii/delete-mii-button";
|
||||
import AuthorButtons from "@/components/mii/author-buttons";
|
||||
import ShareMiiButton from "@/components/mii/share-mii-button";
|
||||
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
|
||||
import SwitchScanTutorialButton from "@/components/tutorial/switch-add-mii";
|
||||
|
|
@ -359,15 +359,7 @@ 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">
|
||||
{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 />
|
||||
</>
|
||||
)}
|
||||
<AuthorButtons mii={mii} />
|
||||
|
||||
<ShareMiiButton miiId={mii.id} />
|
||||
<Link aria-label="Report Mii" href={`/report/mii/${mii.id}`}>
|
||||
|
|
|
|||
|
|
@ -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! aspect-square text-2xl absolute top-4 right-4 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ 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}
|
||||
|
|
|
|||
37
src/components/mii/author-buttons.tsx
Normal file
37
src/components/mii/author-buttons.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ 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" });
|
||||
|
|
@ -85,7 +86,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">
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
|
||||
<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}>
|
||||
|
|
@ -95,13 +96,21 @@ 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" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
<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!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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 };
|
||||
|
|
@ -102,7 +103,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
|
||||
let totalCount: number;
|
||||
let filteredCount: number;
|
||||
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
if (sort === "random") {
|
||||
// Get all IDs that match the where conditions
|
||||
|
|
@ -129,7 +130,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);
|
||||
|
||||
list = await prisma.mii.findMany({
|
||||
miis = await prisma.mii.findMany({
|
||||
where: {
|
||||
id: { in: selectedIds },
|
||||
},
|
||||
|
|
@ -148,7 +149,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
}
|
||||
|
||||
[totalCount, filteredCount, list] = await Promise.all([
|
||||
[totalCount, filteredCount, miis] = await Promise.all([
|
||||
prisma.mii.count({ where: { ...where, userId } }),
|
||||
prisma.mii.count({ where, skip, take: limit }),
|
||||
prisma.mii.findMany({
|
||||
|
|
@ -162,11 +163,6 @@ 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">
|
||||
|
|
@ -193,64 +189,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
</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">
|
||||
{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>
|
||||
|
||||
<MiiGrid miis={miis} />
|
||||
<Pagination lastPage={lastPage} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
88
src/components/mii/list/mii-grid.tsx
Normal file
88
src/components/mii/list/mii-grid.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,12 @@ import { Icon } from "@iconify/react";
|
|||
|
||||
interface Props {
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({ onClick, text = "Submit", className }: Props) {
|
||||
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async (event: React.FormEvent) => {
|
||||
|
|
@ -24,7 +25,7 @@ export default function SubmitButton({ onClick, text = "Submit", className }: Pr
|
|||
};
|
||||
|
||||
return (
|
||||
<button type="submit" aria-label={text} onClick={handleClick} className={`pill button w-min ${className}`}>
|
||||
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
|
||||
{text}
|
||||
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue