mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 06:34:15 +00:00
feat: reimplement punishments
This commit is contained in:
parent
7bd84ea454
commit
77828653ba
34 changed files with 1229 additions and 1068 deletions
75
backend/src/app/admin/page.tsx
Normal file
75
backend/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
24
backend/src/app/api/is-punished/route.ts
Normal file
24
backend/src/app/api/is-punished/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
90
backend/src/app/globals.css
Normal file
90
backend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
16
backend/src/app/layout.tsx
Normal file
16
backend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,3 @@
|
|||
export default function IndexPage() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<p>TomodachiShare API</p>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return <p>TomodachiShare API</p>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue