feat: reimplement punishments

This commit is contained in:
trafficlunar 2026-04-21 13:22:46 +01:00
parent 7bd84ea454
commit 77828653ba
34 changed files with 1229 additions and 1068 deletions

View file

@ -0,0 +1,75 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form";
// import ControlCenter from "@/components/admin/control-center";
import RegenerateImagesButton from "@/components/admin/regenerate-images";
import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports";
import MiiList from "@/components/admin/mii-list";
// import MiiList from "@/components/mii/list";
export const metadata: Metadata = {
title: "Admin - TomodachiShare",
description: "TomodachiShare admin panel",
robots: {
index: false,
follow: false,
},
};
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function AdminPage({ searchParams }: Props) {
const session = await auth();
if (!session || Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) redirect("/");
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
<div>
<h2 className="text-2xl font-bold">Admin Panel</h2>
<p className="text-sm text-zinc-500">View reports, set banners, etc.</p>
</div>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<span>Banners</span>
<hr className="grow border-zinc-300" />
</div>
<BannerForm />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<span>User Management</span>
<hr className="grow border-zinc-300" />
</div>
<UserManagement />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<span>Reports</span>
<hr className="grow border-zinc-300" />
</div>
<Reports searchParams={await searchParams} />
{/* Queue */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<span>Queue</span>
<hr className="grow border-zinc-300" />
</div>
<MiiList searchParams={await searchParams} />
</div>
);
}

View file

@ -29,16 +29,7 @@ export async function GET(request: NextRequest) {
id: true,
type: true,
returned: true,
notes: true,
reasons: true,
violatingMiis: {
select: {
miiId: true,
reason: true,
},
},
reason: true,
expiresAt: true,
createdAt: true,
},

View file

@ -14,16 +14,7 @@ const punishSchema = z.object({
.number({ error: "Duration (days) must be a number" })
.int({ error: "Duration (days) must be an integer" })
.positive({ error: "Duration (days) must be valid" }),
notes: z.string(),
reasons: z.array(z.string()).optional(),
miiReasons: z
.array(
z.object({
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
reason: z.string(),
}),
)
.optional(),
reason: z.string(),
});
export async function POST(request: NextRequest) {
@ -42,7 +33,7 @@ export async function POST(request: NextRequest) {
const parsed = punishSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { type, duration, notes, reasons, miiReasons } = parsed.data;
const { type, duration, reason } = parsed.data;
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
@ -51,14 +42,7 @@ export async function POST(request: NextRequest) {
userId,
type: type as PunishmentType,
expiresAt,
notes,
reasons: reasons?.length !== 0 ? reasons : [],
violatingMiis: {
create: miiReasons?.map((mii) => ({
miiId: mii.id,
reason: mii.reason,
})),
},
reason,
},
});

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 30);
const check = await rateLimit.handle();
if (check) return check;
const activePunishment = await prisma.punishment.findFirst({
where: {
userId: Number(session.user?.id),
returned: false,
},
});
if (!activePunishment) return rateLimit.sendResponse({ isPunished: false, punishment: null });
return rateLimit.sendResponse({ isPunished: true, punishment: activePunishment });
}

View file

@ -17,17 +17,6 @@ export async function POST(request: NextRequest) {
userId: Number(session.user?.id),
returned: false,
},
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
});
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);

View file

@ -0,0 +1,90 @@
@import "tailwindcss";
.pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
}
.button {
@apply hover:bg-orange-400 transition cursor-pointer;
}
.button:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
}
.input {
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
}
.input:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
}
.checkbox {
@apply flex items-center justify-center appearance-none size-5 bg-orange-300 border-2 border-orange-400 rounded-md cursor-pointer checked:bg-orange-400;
}
.checkbox::after {
@apply hidden size-4 bg-cover bg-no-repeat content-[''];
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 13l4 4L19 7' /%3E%3C/svg%3E");
}
.checkbox:checked::after {
@apply block;
}
.checkbox-alt {
@apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all
after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5
after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px]
checked:hover:bg-orange-500 ml-auto;
}
[data-tooltip] {
@apply relative z-10;
}
[data-tooltip]::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 top-full size-0 border-4 border-transparent border-b-orange-400 opacity-0 scale-75 transition-all duration-200 ease-out origin-bottom;
}
[data-tooltip]::after {
@apply content-[attr(data-tooltip)] absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white opacity-0 scale-75 transition-all duration-200 ease-out origin-top shadow-md whitespace-nowrap select-none pointer-events-none;
}
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
@apply opacity-100 scale-100;
}
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
body {
@apply bg-amber-50 text-slate-800 min-h-screen;
font-family: "Lexend Variable", sans-serif;
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
background-image: url('data:image/svg+xml;utf8,\
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">\
<rect width="10" height="10" fill="%23fef3c6"/>\
<rect x="10" y="10" width="10" height="10" fill="%23fef3c6"/>\
<rect x="10" width="10" height="10" fill="%23fffbeb"/>\
<rect y="10" width="10" height="10" fill="%23fffbeb"/>\
</svg>');
background-size: 20px 20px;
}

View file

@ -0,0 +1,16 @@
import "./globals.css";
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html>
<head>
<title>TomodachiShare API</title>
</head>
<body>{children}</body>
</html>
);
}

View file

@ -1,122 +0,0 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import dayjs from "dayjs";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// import ReturnToIsland from "@/components/admin/return-to-island";
export const metadata: Metadata = {
title: "Exiled - TomodachiShare",
description: "You have been exiled from the TomodachiShare island...",
robots: {
index: false,
follow: false,
},
};
export default async function ExiledPage() {
const session = await auth();
if (!session?.user) redirect("/");
const activePunishment = await prisma.punishment.findFirst({
where: {
userId: Number(session?.user.id),
returned: false,
},
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
});
if (!activePunishment) redirect("/");
const expiresAt = dayjs(activePunishment.expiresAt);
const createdAt = dayjs(activePunishment.createdAt);
const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date();
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
<h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE"
? "Exiled permanently"
: activePunishment.type === "TEMP_EXILE"
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
: "Warning"}
</h2>
<p>
You have been exiled from the TomodachiShare island because you violated the{" "}
<Link href={"/terms-of-service"} className="text-blue-500">
Terms of Service
</Link>
.
</p>
<p className="mt-3">
<span className="font-bold">Reviewed:</span> {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "}
{activePunishment.createdAt.toLocaleString("en-GB")}
</p>
<p className="mt-1">
<span className="font-bold">Note:</span> {activePunishment.notes}
</p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="grow border-zinc-300" />
<span>Violating Items</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-2 p-4">
{activePunishment.reasons.map((index, reason) => (
<div key={index} className="bg-orange-100 rounded-xl border-2 border-orange-400 p-4">
<p>
<span className="font-bold">Reason:</span> {reason}
</p>
</div>
))}
{activePunishment.violatingMiis.map((mii) => (
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>
</div>
</div>
))}
</div>
<hr className="border-zinc-300 mt-2 mb-4" />
{activePunishment.type !== "PERM_EXILE" ? (
<>
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
{/* <ReturnToIsland hasExpired={hasExpired} /> */}
</>
) : (
<>
<p>Your punishment is permanent, therefore you cannot return.</p>
</>
)}
</div>
</div>
);
}

View file

@ -1,9 +1,3 @@
export default function IndexPage() {
return (
<html>
<body>
<p>TomodachiShare API</p>
</body>
</html>
);
return <p>TomodachiShare API</p>;
}

View file

@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
export default function BannerForm() {
const [message, setMessage] = useState("");
const onClickClear = async () => {
await fetch(`/api/admin/banner`, { method: "DELETE" });
};
const onClickSet = async () => {
await fetch(`/api/admin/banner`, { method: "POST", body: message });
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="pill button" onClick={onClickClear}>
Clear
</button>
<button type="submit" className="pill button" onClick={onClickSet}>
Set
</button>
</div>
);
}

View file

@ -0,0 +1,45 @@
// import { settings } from "@/lib/settings";
// import { useState } from "react";
// export default function ControlCenter() {
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
// const onClickSet = async () => {
// await fetch("/api/admin/can-submit", { method: "POST", body: JSON.stringify(canSubmit) });
// await fetch("/api/admin/queue", { method: "POST", body: JSON.stringify(isQueueEnabled) });
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
// <div className="flex items-center gap-2">
// <input
// id="submit"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={canSubmit}
// onChange={(e) => setCanSubmit(e.target.checked)}
// />
// <label htmlFor="submit">Enable Submissions</label>
// </div>
// <div className="flex items-center gap-2">
// <input
// id="queue"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={isQueueEnabled}
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
// />
// <label htmlFor="queue">Enable Queue</label>
// </div>
// <div className="flex gap-2 self-end">
// <button type="submit" className="pill button" onClick={onClickSet}>
// Set
// </button>
// </div>
// </div>
// );
// }

View file

@ -0,0 +1,129 @@
"use client";
import { Icon } from "@iconify/react";
import { Prisma } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Pagination from "../pagination";
interface Props {
miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } } } }>[];
totalCount: number;
lastPage: number;
}
export default function MiiGrid({ miis, totalCount, lastPage }: Props) {
const router = useRouter();
const acceptMii = async (id: number) => {
await fetch(`/api/admin/accept-mii?id=${id}`, { method: "POST" });
router.refresh();
};
const acceptMany = async (ids: number[]) => {
await Promise.all(ids.map((id) => fetch(`/api/admin/accept-mii?id=${id}`, { method: "POST" })));
router.refresh();
};
const rows: (typeof miis)[] = [];
for (let i = 0; i < miis.length; i += 4) rows.push(miis.slice(i, i + 4));
return (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
</div>
<button
onClick={() => acceptMany(miis.map((m) => m.id))}
className="cursor-pointer text-sm font-semibold text-white bg-green-500 hover:bg-green-600 transition-colors px-4 py-2 rounded-xl shadow flex items-center gap-2"
>
<Icon icon="material-symbols:check-circle-rounded" />
Accept all ({miis.length})
</button>
</div>
<div className="flex flex-col gap-2">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex flex-col gap-2">
<div className="flex justify-end">
<button
onClick={() => acceptMany(row.map((m) => m.id))}
className="cursor-pointer text-xs text-zinc-400 hover:text-green-500 border border-zinc-200 hover:border-green-500 bg-white px-2 py-1 rounded-md shadow-sm flex items-center gap-1"
>
<Icon icon="material-symbols:check-circle-outline-rounded" />
Accept row
</button>
</div>
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{row.map((mii) => (
<div
key={mii.id}
className={`flex flex-col relative bg-zinc-50 border-zinc-300 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 bg-red-50!" : ""}`}
>
{mii.in_queue && (
<div className="absolute top-2 left-2 z-10 bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1">
<Icon icon="mdi:clock-outline" className="text-base" />
In Queue
</div>
)}
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between items-center">
<Link
href={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/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: string) => (
<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">
<Link
href={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/profile/${mii.user?.id}`}
className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap"
>
@{mii.user?.name}
</Link>
<div className="flex justify-between w-full col-span-2 mt-2">
<div className="flex gap-1 text-3xl justify-center">
<button
onClick={() => acceptMii(mii.id)}
className="cursor-pointer text-zinc-400 hover:text-green-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-green-500"
title="Accept Mii"
>
<Icon icon="material-symbols:check-rounded" />
</button>
</div>
<span className="text-sm w-1/2 text-right">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
<Pagination lastPage={lastPage} />
</div>
);
}

View file

@ -0,0 +1,35 @@
import { Prisma } from "@prisma/client";
import { searchSchema } from "@tomodachi-share/shared/schemas";
import { prisma } from "@/lib/prisma";
import MiiGrid from "./mii-grid";
interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}
export default async function MiiList({ searchParams }: Props) {
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { page = 1, limit = 24 } = parsed.data;
const skip = (page - 1) * limit;
let totalCount: number;
let miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } } } }>[];
[totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { in_queue: true } }),
prisma.mii.findMany({
where: { in_queue: true },
include: { user: { select: { id: true, name: true } } },
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
skip,
take: limit,
}),
]);
const lastPage = Math.ceil(totalCount / limit);
return <MiiGrid miis={miis} totalCount={totalCount} lastPage={lastPage} />;
}

View file

@ -0,0 +1,94 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
interface Props {
punishmentId: number;
}
export default function PunishmentDeletionDialog({ punishmentId }: Props) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
router.refresh();
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
<Icon icon="material-symbols:close-rounded" />
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Punishment Deletion</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">Are you sure? This will delete the user&lsquo;s punishment and they will be able to come back.</p>
{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} />
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../../../../frontend/src/components/submit-button";
export default function RegenerateImagesButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "POST" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
close();
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
Regenerate all Mii metadata images
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Regenerate Images</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
{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} />
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { ReportStatus } from "@prisma/client";
export default function ReportTabs({ status }: { status?: ReportStatus }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
{["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
<button
key={s}
onClick={() =>
startTransition(() => {
router.push(s === "ALL" ? "/admin" : `/admin?status=${s}`, { scroll: false });
})
}
className={`text-sm px-3 py-1 rounded-full font-medium cursor-pointer border transition-colors ${
(s === "ALL" && !status) || s === status
? "bg-orange-400 text-white border-orange-400"
: "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
}`}
>
{s}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,202 @@
import { revalidatePath } from "next/cache";
import { Icon } from "@iconify/react";
import { ReportStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import ReportTabs from "./report-tabs";
const PAGE_SIZE = 20;
export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
const status = searchParams.status as ReportStatus | undefined;
const page = Number(searchParams.page ?? 1);
const [reports, total] = await Promise.all([
prisma.report.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.report.count({
where: status ? { status } : undefined,
}),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const updateStatus = async (formData: FormData) => {
"use server";
const id = Number(formData.get("id"));
const status = formData.get("status") as ReportStatus;
await prisma.report.update({
where: { id },
data: { status },
});
revalidatePath("/admin");
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400">
<ReportTabs status={status} />
{/* Grid */}
<div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
{reports.map((report) => (
<div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
<div className="w-full overflow-x-scroll">
<div className="flex gap-1 w-max">
<span
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
}`}
>
{report.reportType}
</span>
<span
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
report.status == "OPEN"
? "bg-orange-200 text-orange-800 border-orange-400"
: report.status == "RESOLVED"
? "bg-green-200 text-green-800 border-green-400"
: "bg-zinc-200 text-zinc-800 border-zinc-400"
}`}
>
{report.status}
</span>
<span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
<Icon icon="lucide:calendar" className="text-base" />
{report.createdAt.toLocaleString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
})}{" "}
UTC
</span>
</div>
</div>
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
<div>
<p>Target ID</p>
<a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
{report.targetId}
</a>
</div>
<div>
<p>Creator ID</p>
<a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
{report.creatorId}
</a>
</div>
<div>
<p>Reporter</p>
<a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
{report.authorId}
</a>
</div>
<div>
<p>Reason</p>
<p className="font-medium text-black text-sm">{report.reason}</p>
</div>
</div>
<div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
<p className="text-zinc-600 text-xs">Notes</p>
<p>{report.reasonNotes}</p>
</div>
<div className="mt-4 flex gap-4">
<form action={updateStatus}>
<input type="hidden" name="id" value={report.id} />
<input type="hidden" name="status" value={"OPEN"} />
<button
type="submit"
aria-label="Open"
className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
>
<Icon icon="mdi:alert-circle" className="text-xl" />
<span className="text-sm">Open</span>
</button>
</form>
<form action={updateStatus}>
<input type="hidden" name="id" value={report.id} />
<input type="hidden" name="status" value={"RESOLVED"} />
<button
type="submit"
aria-label="Resolve"
className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
>
<Icon icon="mdi:check-circle" className="text-xl" />
<span className="text-sm">Resolve</span>
</button>
</form>
<form action={updateStatus}>
<input type="hidden" name="id" value={report.id} />
<input type="hidden" name="status" value={"DISMISSED"} />
<button
type="submit"
aria-label="Dismiss"
className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
>
<Icon icon="mdi:close-circle" className="text-xl" />
<span className="text-sm">Dismiss</span>
</button>
</form>
</div>
</div>
))}
</div>
{reports.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg font-medium">No reports to display</p>
<p className="text-sm">Reports will appear here when users submit them</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center p-3 border-t border-orange-300">
<span className="text-sm text-orange-700">{total} total</span>
<div className="flex items-center gap-3">
{page > 1 && (
<a
href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
>
Previous
</a>
)}
<span className="text-sm text-orange-700">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<a
href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
>
Next
</a>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import { Punishment, PunishmentType } from "@prisma/client";
import PunishmentDeletionDialog from "./punishment-deletion-dialog";
import SubmitButton from "../submit-button";
interface ApiResponse {
success: boolean;
name: string;
image: string;
createdAt: string;
punishments: Punishment[];
}
export default function Punishments() {
const [userId, setUserId] = useState(-1);
const [user, setUser] = useState<ApiResponse | undefined>();
const [type, setType] = useState<PunishmentType>("WARNING");
const [duration, setDuration] = useState(1);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const handleLookup = async () => {
const response = await fetch(`/api/admin/lookup?id=${userId}`);
const data = await response.json();
if (!response.ok) {
setError(data.error);
setUser(undefined);
return;
}
setError(undefined);
setUser(data);
};
const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${userId}`, {
method: "POST",
body: JSON.stringify({
type,
duration,
reason,
}),
});
if (!response.ok) {
const { error } = await response.json();
setError(error);
}
// Set all inputs to empty/default
setType("WARNING");
setDuration(1);
setReason("");
setError("");
await handleLookup();
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
<div className="flex justify-center items-center gap-2">
<input
type="number"
placeholder="Enter user ID to lookup..."
name="user-id"
value={userId !== -1 ? userId : ""}
onChange={(e) => setUserId(Number(e.target.value))}
className="pill input w-full max-w-lg"
/>
<button onClick={handleLookup} className="pill button">
Lookup User
</button>
</div>
{user && (
<div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
<div className="flex gap-1">
<img src={user.image} width={96} height={96} alt="profile_picture" className="rounded-full border-2 border-orange-400" />
<div className="p-2 flex flex-col">
<p className="text-xl font-bold">{user.name}</p>
<p className="text-black/60 text-sm font-medium">@{user.name}</p>
<p className="text-sm mt-auto">
<span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
})}{" "}
UTC
</p>
</div>
</div>
<hr className="border-zinc-300 my-3" />
<div className="flex flex-col gap-2">
{user.punishments.length === 0 ? (
<p className="text-center text-zinc-500 my-2">No punishments found.</p>
) : (
<>
{user.punishments.map((punishment) => (
<div
key={punishment.id}
className={`border rounded-lg p-3 space-y-1 ${
punishment.type === "WARNING"
? "bg-yellow-50 border-yellow-400"
: punishment.type === "TEMP_EXILE"
? "bg-orange-100 border-orange-200"
: "bg-red-50 border-red-200"
}`}
>
<div className="flex items-center justify-between mb-2">
<span
className={`border px-2 py-1 rounded text-xs font-semibold ${
punishment.type === "WARNING"
? "bg-yellow-200 text-yellow-800 border-yellow-500"
: punishment.type === "TEMP_EXILE"
? "bg-orange-200 text-orange-800 border-orange-500"
: "bg-red-200 text-red-800 border-red-500"
}`}
>
{punishment.type}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-600">
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
</span>
<PunishmentDeletionDialog punishmentId={punishment.id} />
</div>
</div>
<p className="text-sm text-zinc-600">
<strong>Reason:</strong> {punishment.reason}
</p>
{punishment.type !== "WARNING" && (
<p className="text-sm text-zinc-600">
<strong>Expires:</strong>{" "}
{punishment.expiresAt
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
: "Never"}
</p>
)}
{punishment.type !== "PERM_EXILE" && (
<p className="text-sm text-zinc-600">
<strong>Returned:</strong> {JSON.stringify(punishment.returned)}
</p>
)}
</div>
))}
</>
)}
</div>
</div>
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
{/* Punishment type */}
<p className="text-sm">Punishment Type</p>
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
<option value="WARNING">Warning</option>
<option value="TEMP_EXILE">Temporary Exile</option>
<option value="PERM_EXILE">Permanent Exile</option>
</select>
{/* Punishment duration */}
{type === "TEMP_EXILE" && (
<>
<p className="text-sm">Duration</p>
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
<option value="1">1 Day</option>
<option value="7">7 Days</option>
<option value="30">30 Days</option>
</select>
</>
)}
{/* Punishment reason */}
<p className="text-sm">Reason</p>
<textarea
rows={2}
maxLength={256}
placeholder="Type the reason here for the punishment..."
className="pill input rounded-xl! resize-none"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<div className="flex justify-between items-center mt-2">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { Icon } from "@iconify/react";
interface Props {
lastPage: number;
}
export default function Pagination({ lastPage }: Props) {
const pathname = usePathname();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page") ?? 1);
const createPageUrl = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
},
[searchParams, pathname],
);
const numbers = useMemo(() => {
const result = [];
// Always show 5 pages, centering around the current page when possible
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
const end = Math.min(lastPage, start + 4);
for (let i = start; i <= end; i++) result.push(i);
return result;
}, [page, lastPage]);
return (
<div className="flex justify-center items-center w-full mt-8">
{/* Go to first page */}
<Link
href={page === 1 ? "#" : createPageUrl(1)}
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-left" />
</Link>
{/* Previous page */}
<Link
href={page === 1 ? "#" : createPageUrl(page - 1)}
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-left" />
</Link>
{/* Page numbers */}
<div className="flex mx-2">
{numbers.map((number) => (
<Link
key={number}
href={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
>
{number}
</Link>
))}
</div>
{/* Next page */}
<Link
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-right" />
</Link>
{/* Go to last page */}
<Link
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-right" />
</Link>
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
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) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<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>
);
}