From aef188f7c800907a911e3e1aa8fb5e3db1dfe702 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 31 May 2025 22:40:56 +0100 Subject: [PATCH] feat: punishment page --- .../migration.sql | 2 + prisma/schema.prisma | 7 +- src/app/api/admin/lookup/route.ts | 6 + src/app/api/admin/punish/route.ts | 21 +++ src/app/api/return/route.ts | 48 +++++++ src/app/globals.css | 4 + src/app/off-the-island/page.tsx | 124 ++++++++++++++++++ src/app/page.tsx | 10 +- src/app/submit/page.tsx | 9 ++ .../admin/punishment-deletion-dialog.tsx | 94 +++++++++++++ src/components/admin/return-to-island.tsx | 53 ++++++++ src/components/admin/user-management.tsx | 52 ++++---- 12 files changed, 401 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20250531210837_punishments_returned/migration.sql create mode 100644 src/app/api/return/route.ts create mode 100644 src/app/off-the-island/page.tsx create mode 100644 src/components/admin/punishment-deletion-dialog.tsx create mode 100644 src/components/admin/return-to-island.tsx diff --git a/prisma/migrations/20250531210837_punishments_returned/migration.sql b/prisma/migrations/20250531210837_punishments_returned/migration.sql new file mode 100644 index 0000000..51a04bd --- /dev/null +++ b/prisma/migrations/20250531210837_punishments_returned/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "punishments" ADD COLUMN "returned" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9eaf964..ebd21f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,9 +136,10 @@ model MiiPunishment { } model Punishment { - id Int @id @default(autoincrement()) - userId Int - type PunishmentType + id Int @id @default(autoincrement()) + userId Int + type PunishmentType + returned Boolean @default(false) notes String reasons String[] diff --git a/src/app/api/admin/lookup/route.ts b/src/app/api/admin/lookup/route.ts index 3020f3c..e5307f3 100644 --- a/src/app/api/admin/lookup/route.ts +++ b/src/app/api/admin/lookup/route.ts @@ -22,9 +22,14 @@ export async function GET(request: NextRequest) { }, include: { punishments: { + orderBy: { + createdAt: "desc", + }, select: { id: true, type: true, + returned: true, + notes: true, reasons: true, violatingMiis: { @@ -33,6 +38,7 @@ export async function GET(request: NextRequest) { reason: true, }, }, + expiresAt: true, createdAt: true, }, diff --git a/src/app/api/admin/punish/route.ts b/src/app/api/admin/punish/route.ts index 80000d2..c386f6a 100644 --- a/src/app/api/admin/punish/route.ts +++ b/src/app/api/admin/punish/route.ts @@ -67,3 +67,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true }); } + +export async function DELETE(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const searchParams = request.nextUrl.searchParams; + const parsedPunishmentId = idSchema.safeParse(searchParams.get("id")); + + if (!parsedPunishmentId.success) return NextResponse.json({ error: parsedPunishmentId.error.errors[0].message }, { status: 400 }); + const punishmentId = parsedPunishmentId.data; + + await prisma.punishment.delete({ + where: { + id: punishmentId, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/return/route.ts b/src/app/api/return/route.ts new file mode 100644 index 0000000..c3cb3eb --- /dev/null +++ b/src/app/api/return/route.ts @@ -0,0 +1,48 @@ +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 DELETE(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const rateLimit = new RateLimit(request, 1); + const check = await rateLimit.handle(); + if (check) return check; + + const activePunishment = await prisma.punishment.findFirst({ + where: { + 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); + if (activePunishment.type === "PERM_EXILE") return rateLimit.sendResponse({ error: "Your punishment is permanent" }, 403); + if (activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date()) + return rateLimit.sendResponse({ error: "Your punishment has not expired yet." }, 403); + + await prisma.punishment.update({ + where: { + id: activePunishment.id, + }, + data: { + returned: true, + }, + }); + + return rateLimit.sendResponse({ success: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index ffec983..40a1877 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,10 @@ body { @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; } diff --git a/src/app/off-the-island/page.tsx b/src/app/off-the-island/page.tsx new file mode 100644 index 0000000..cea9dd9 --- /dev/null +++ b/src/app/off-the-island/page.tsx @@ -0,0 +1,124 @@ +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 ( +
+
+

+ {activePunishment.type === "PERM_EXILE" + ? "Exiled permanently" + : activePunishment.type === "TEMP_EXILE" + ? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}` + : "Warning"} +

+

+ You have been exiled from the TomodachiShare island because you violated the{" "} + + Terms of Service + + . +

+ +

+ Reviewed: {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "} + {activePunishment.createdAt.toLocaleString("en-GB")} +

+ +

+ Note: {activePunishment.notes} +

+ +
+
+ Violating Items +
+
+ +
+ {activePunishment.reasons.map((index, reason) => ( +
+

+ Reason: {reason} +

+
+ ))} + {activePunishment.violatingMiis.map((mii) => ( +
+ mii image +
+

+ {mii.mii.name} +

+

+ Reason: {mii.reason} +

+
+
+ ))} +
+ +
+ + {activePunishment.type !== "PERM_EXILE" ? ( + <> +

Once your punishment ends, you can return by checking the box below.

+ + + ) : ( + <> +

Your punishment is permanent, therefore you cannot return.

+ + )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0b63f2b..1a79185 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,12 @@ +import { Metadata } from "next"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; import MiiList from "@/components/mii-list"; import Skeleton from "@/components/mii-list/skeleton"; -import { Metadata } from "next"; interface Props { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; @@ -39,6 +40,13 @@ export default async function Page({ searchParams }: Props) { if (session?.user && !session.user.username) { redirect("/create-username"); } + const activePunishment = await prisma.punishment.findFirst({ + where: { + userId: Number(session?.user.id), + returned: false, + }, + }); + if (activePunishment) redirect("/off-the-island"); return ( <> diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index 90d5dde..ef0dbd9 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -5,6 +5,8 @@ import Link from "next/link"; import { Icon } from "@iconify/react"; import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + import SubmitForm from "@/components/submit-form"; export const metadata: Metadata = { @@ -21,6 +23,13 @@ export default async function SubmitPage() { if (!session) redirect("/login"); if (!session.user.username) redirect("/create-username"); + const activePunishment = await prisma.punishment.findFirst({ + where: { + userId: Number(session?.user.id), + returned: false, + }, + }); + if (activePunishment) redirect("/off-the-island"); // Check if submissions are disabled const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); diff --git a/src/components/admin/punishment-deletion-dialog.tsx b/src/components/admin/punishment-deletion-dialog.tsx new file mode 100644 index 0000000..9dd0981 --- /dev/null +++ b/src/components/admin/punishment-deletion-dialog.tsx @@ -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(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 ( + <> + + + {isOpen && + createPortal( +
+
+ +
+
+

Punishment Deletion

+ +
+ +

Are you sure? This will delete the user‘s punishment and they will be able to come back.

+ + {error && Error: {error}} + +
+ + +
+
+
, + document.body + )} + + ); +} diff --git a/src/components/admin/return-to-island.tsx b/src/components/admin/return-to-island.tsx new file mode 100644 index 0000000..ab49847 --- /dev/null +++ b/src/components/admin/return-to-island.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { Icon } from "@iconify/react"; +import { redirect } from "next/navigation"; + +interface Props { + hasExpired: boolean; +} + +export default function ReturnToIsland({ hasExpired }: Props) { + const [isChecked, setIsChecked] = useState(false); + const [error, setError] = useState(undefined); + + const handleClick = async () => { + const response = await fetch("/api/return", { method: "DELETE" }); + + if (!response.ok) { + const data = await response.json(); + setError(data.error); + + return; + } + + redirect("/"); + }; + + return ( + <> +
+ setIsChecked(e.target.checked)} + className={`checkbox ${hasExpired && "text-zinc-600 !bg-zinc-100 !border-zinc-300"}`} + /> + +
+ +
+ + {error && Error: {error}} + + + ); +} diff --git a/src/components/admin/user-management.tsx b/src/components/admin/user-management.tsx index 684a671..5632deb 100644 --- a/src/components/admin/user-management.tsx +++ b/src/components/admin/user-management.tsx @@ -6,9 +6,10 @@ import Image from "next/image"; import { useState } from "react"; import { Icon } from "@iconify/react"; -import { PunishmentType } from "@prisma/client"; +import { Prisma, PunishmentType } from "@prisma/client"; import SubmitButton from "../submit-button"; +import PunishmentDeletionDialog from "./punishment-deletion-dialog"; interface ApiResponse { success: boolean; @@ -16,21 +17,11 @@ interface ApiResponse { username: string; image: string; createdAt: string; - punishments: { - id: number; - userId: number; - type: string; - - notes: string; - reasons: string[]; - violatingMiis: { - miiId: number; - reason: string; - }[]; - - expiresAt: string | null; - createdAt: string; - }[]; + punishments: Prisma.PunishmentGetPayload<{ + include: { + violatingMiis: true; + }; + }>[]; } interface MiiList { @@ -172,19 +163,30 @@ export default function Punishments() { > {punishment.type} - - {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} - + +
+ + {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} + + +

Notes: {punishment.notes}

-

- Expires:{" "} - {punishment.expiresAt - ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) - : "Never"} -

+ {punishment.type !== "WARNING" && ( +

+ Expires:{" "} + {punishment.expiresAt + ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) + : "Never"} +

+ )} + {punishment.type !== "PERM_EXILE" && ( +

+ Returned: {JSON.stringify(punishment.returned)} +

+ )}

Reasons: