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