From 77828653ba8ebabc86e039875f16cc439bc9b584 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Tue, 21 Apr 2026 13:22:46 +0100 Subject: [PATCH] feat: reimplement punishments --- .../migration.sql | 25 ++ backend/prisma/schema.prisma | 26 +- backend/src/app/admin/page.tsx | 75 +++++ backend/src/app/api/admin/lookup/route.ts | 11 +- backend/src/app/api/admin/punish/route.ts | 22 +- backend/src/app/api/is-punished/route.ts | 24 ++ backend/src/app/api/return/route.ts | 11 - backend/src/app/globals.css | 90 +++++ backend/src/app/layout.tsx | 16 + backend/src/app/off-the-island/page.tsx | 122 ------- backend/src/app/page.tsx | 8 +- .../src/components/admin/banner-form.tsx | 7 +- .../src/components/admin/control-center.tsx | 0 backend/src/components/admin/mii-grid.tsx | 129 +++++++ backend/src/components/admin/mii-list.tsx | 35 ++ .../admin/punishment-deletion-dialog.tsx | 94 ++++++ .../components/admin/regenerate-images.tsx | 2 +- backend/src/components/admin/report-tabs.tsx | 32 ++ backend/src/components/admin/reports.tsx | 202 +++++++++++ .../src/components/admin/user-management.tsx | 207 ++++++++++++ backend/src/components/pagination.tsx | 101 ++++++ backend/src/components/submit-button.tsx | 33 ++ backend/tsconfig.json | 2 - .../admin/punishment-deletion-dialog.tsx | 92 ----- frontend/src/components/admin/queue.tsx | 166 --------- frontend/src/components/admin/report-tabs.tsx | 30 -- frontend/src/components/admin/reports.tsx | 202 ----------- .../src/components/admin/return-to-island.tsx | 87 +++-- .../src/components/admin/user-management.tsx | 316 ------------------ frontend/src/layout.tsx | 27 ++ frontend/src/main.tsx | 4 +- frontend/src/pages/admin.tsx | 17 - frontend/src/pages/profile/layout.tsx | 4 +- frontend/src/pages/punished.tsx | 78 +++++ 34 files changed, 1229 insertions(+), 1068 deletions(-) create mode 100644 backend/prisma/migrations/20260421113133_useless_punishment_fields/migration.sql create mode 100644 backend/src/app/admin/page.tsx create mode 100644 backend/src/app/api/is-punished/route.ts create mode 100644 backend/src/app/globals.css create mode 100644 backend/src/app/layout.tsx delete mode 100644 backend/src/app/off-the-island/page.tsx rename {frontend => backend}/src/components/admin/banner-form.tsx (72%) rename {frontend => backend}/src/components/admin/control-center.tsx (100%) create mode 100644 backend/src/components/admin/mii-grid.tsx create mode 100644 backend/src/components/admin/mii-list.tsx create mode 100644 backend/src/components/admin/punishment-deletion-dialog.tsx rename {frontend => backend}/src/components/admin/regenerate-images.tsx (96%) create mode 100644 backend/src/components/admin/report-tabs.tsx create mode 100644 backend/src/components/admin/reports.tsx create mode 100644 backend/src/components/admin/user-management.tsx create mode 100644 backend/src/components/pagination.tsx create mode 100644 backend/src/components/submit-button.tsx delete mode 100644 frontend/src/components/admin/punishment-deletion-dialog.tsx delete mode 100644 frontend/src/components/admin/queue.tsx delete mode 100644 frontend/src/components/admin/report-tabs.tsx delete mode 100644 frontend/src/components/admin/reports.tsx delete mode 100644 frontend/src/components/admin/user-management.tsx delete mode 100644 frontend/src/pages/admin.tsx create mode 100644 frontend/src/pages/punished.tsx diff --git a/backend/prisma/migrations/20260421113133_useless_punishment_fields/migration.sql b/backend/prisma/migrations/20260421113133_useless_punishment_fields/migration.sql new file mode 100644 index 0000000..e7d52ac --- /dev/null +++ b/backend/prisma/migrations/20260421113133_useless_punishment_fields/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `punishmentId` on the `miis` table. All the data in the column will be lost. + - You are about to drop the column `notes` on the `punishments` table. All the data in the column will be lost. + - You are about to drop the column `reasons` on the `punishments` table. All the data in the column will be lost. + - You are about to drop the `mii_punishments` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `reason` to the `punishments` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "mii_punishments" DROP CONSTRAINT "mii_punishments_miiId_fkey"; + +-- DropForeignKey +ALTER TABLE "mii_punishments" DROP CONSTRAINT "mii_punishments_punishmentId_fkey"; + +-- AlterTable +ALTER TABLE "miis" DROP COLUMN "punishmentId"; + +-- AlterTable +ALTER TABLE "punishments" RENAME COLUMN "notes" TO "reason"; +ALTER TABLE "punishments" DROP COLUMN "reasons"; + +-- DropTable +DROP TABLE "mii_punishments"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dca9d42..041eb4e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -90,12 +90,9 @@ model Mii { createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - likeCount Int @default(0) - - punishmentId Int? - punishments MiiPunishment[] - likedBy Like[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + likeCount Int @default(0) + likedBy Like[] @@index([tags], type: Gin) @@index([createdAt]) @@ -142,28 +139,13 @@ model Report { @@map("reports") } -model MiiPunishment { - punishmentId Int - miiId Int - reason String - - punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade) - mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade) - - @@id([punishmentId, miiId]) - @@map("mii_punishments") -} - model Punishment { id Int @id @default(autoincrement()) userId Int type PunishmentType returned Boolean @default(false) - notes String - reasons String[] - violatingMiis MiiPunishment[] - + reason String expiresAt DateTime? createdAt DateTime @default(now()) diff --git a/backend/src/app/admin/page.tsx b/backend/src/app/admin/page.tsx new file mode 100644 index 0000000..fa60504 --- /dev/null +++ b/backend/src/app/admin/page.tsx @@ -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 ( +
+
+

Admin Panel

+

View reports, set banners, etc.

+
+ + {/* Separator */} +
+
+ Banners +
+
+ + + + {/* Separator */} +
+
+ User Management +
+
+ + + + {/* Separator */} +
+
+ Reports +
+
+ + + + {/* Queue */} +
+
+ Queue +
+
+ +
+ ); +} diff --git a/backend/src/app/api/admin/lookup/route.ts b/backend/src/app/api/admin/lookup/route.ts index 0ecde93..47eb688 100644 --- a/backend/src/app/api/admin/lookup/route.ts +++ b/backend/src/app/api/admin/lookup/route.ts @@ -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, }, diff --git a/backend/src/app/api/admin/punish/route.ts b/backend/src/app/api/admin/punish/route.ts index c77d2ae..397dce7 100644 --- a/backend/src/app/api/admin/punish/route.ts +++ b/backend/src/app/api/admin/punish/route.ts @@ -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, }, }); diff --git a/backend/src/app/api/is-punished/route.ts b/backend/src/app/api/is-punished/route.ts new file mode 100644 index 0000000..c843d55 --- /dev/null +++ b/backend/src/app/api/is-punished/route.ts @@ -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 }); +} diff --git a/backend/src/app/api/return/route.ts b/backend/src/app/api/return/route.ts index a83887c..edda24f 100644 --- a/backend/src/app/api/return/route.ts +++ b/backend/src/app/api/return/route.ts @@ -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); diff --git a/backend/src/app/globals.css b/backend/src/app/globals.css new file mode 100644 index 0000000..a88b5d6 --- /dev/null +++ b/backend/src/app/globals.css @@ -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,\ + \ + \ + \ + \ + \ + '); + background-size: 20px 20px; +} diff --git a/backend/src/app/layout.tsx b/backend/src/app/layout.tsx new file mode 100644 index 0000000..493baaa --- /dev/null +++ b/backend/src/app/layout.tsx @@ -0,0 +1,16 @@ +import "./globals.css"; + +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + TomodachiShare API + + {children} + + ); +} diff --git a/backend/src/app/off-the-island/page.tsx b/backend/src/app/off-the-island/page.tsx deleted file mode 100644 index 6d2810b..0000000 --- a/backend/src/app/off-the-island/page.tsx +++ /dev/null @@ -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 ( -
-
-

- {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/backend/src/app/page.tsx b/backend/src/app/page.tsx index 252958b..3efb5f7 100644 --- a/backend/src/app/page.tsx +++ b/backend/src/app/page.tsx @@ -1,9 +1,3 @@ export default function IndexPage() { - return ( - - -

TomodachiShare API

- - - ); + return

TomodachiShare API

; } diff --git a/frontend/src/components/admin/banner-form.tsx b/backend/src/components/admin/banner-form.tsx similarity index 72% rename from frontend/src/components/admin/banner-form.tsx rename to backend/src/components/admin/banner-form.tsx index d9c02b0..afc5d3f 100644 --- a/frontend/src/components/admin/banner-form.tsx +++ b/backend/src/components/admin/banner-form.tsx @@ -1,15 +1,16 @@ +"use client"; + import { useState } from "react"; export default function BannerForm() { const [message, setMessage] = useState(""); - const API_URL = import.meta.env.VITE_API_URL; const onClickClear = async () => { - await fetch(`${API_URL}/api/admin/banner`, { method: "DELETE", credentials: "include" }); // TODO + await fetch(`/api/admin/banner`, { method: "DELETE" }); }; const onClickSet = async () => { - await fetch(`${API_URL}/api/admin/banner`, { method: "POST", body: message, credentials: "include" }); + await fetch(`/api/admin/banner`, { method: "POST", body: message }); }; return ( diff --git a/frontend/src/components/admin/control-center.tsx b/backend/src/components/admin/control-center.tsx similarity index 100% rename from frontend/src/components/admin/control-center.tsx rename to backend/src/components/admin/control-center.tsx diff --git a/backend/src/components/admin/mii-grid.tsx b/backend/src/components/admin/mii-grid.tsx new file mode 100644 index 0000000..5bf31ec --- /dev/null +++ b/backend/src/components/admin/mii-grid.tsx @@ -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 ( +
+
+
+ {totalCount} + {totalCount === 1 ? "Mii" : "Miis"} +
+ +
+ +
+ {rows.map((row, rowIndex) => ( +
+
+ +
+
+ {row.map((mii) => ( +
+ {mii.in_queue && ( +
+ + In Queue +
+ )} + +
+
+ + {mii.name} + +
+ {mii.platform === "SWITCH" ? ( + + ) : ( + + )} +
+
+
+ {mii.tags.map((tag: string) => ( + + {tag} + + ))} +
+ +
+ + @{mii.user?.name} + + +
+
+ +
+ + {mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })} +
+
+
+
+ ))} +
+
+ ))} +
+ +
+ ); +} diff --git a/backend/src/components/admin/mii-list.tsx b/backend/src/components/admin/mii-list.tsx new file mode 100644 index 0000000..a62dbe4 --- /dev/null +++ b/backend/src/components/admin/mii-list.tsx @@ -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

{parsed.error.issues[0].message}

; + + 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 ; +} diff --git a/backend/src/components/admin/punishment-deletion-dialog.tsx b/backend/src/components/admin/punishment-deletion-dialog.tsx new file mode 100644 index 0000000..8b58d44 --- /dev/null +++ b/backend/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/frontend/src/components/admin/regenerate-images.tsx b/backend/src/components/admin/regenerate-images.tsx similarity index 96% rename from frontend/src/components/admin/regenerate-images.tsx rename to backend/src/components/admin/regenerate-images.tsx index 8e15372..bf387c6 100644 --- a/frontend/src/components/admin/regenerate-images.tsx +++ b/backend/src/components/admin/regenerate-images.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Icon } from "@iconify/react"; -import SubmitButton from "../submit-button"; +import SubmitButton from "../../../../frontend/src/components/submit-button"; export default function RegenerateImagesButton() { const [isOpen, setIsOpen] = useState(false); diff --git a/backend/src/components/admin/report-tabs.tsx b/backend/src/components/admin/report-tabs.tsx new file mode 100644 index 0000000..d29339e --- /dev/null +++ b/backend/src/components/admin/report-tabs.tsx @@ -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 ( +
+ {["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => ( + + ))} +
+ ); +} diff --git a/backend/src/components/admin/reports.tsx b/backend/src/components/admin/reports.tsx new file mode 100644 index 0000000..7eb75ed --- /dev/null +++ b/backend/src/components/admin/reports.tsx @@ -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 ( +
+ + + {/* Grid */} +
+ {reports.map((report) => ( +
+
+
+ + {report.reportType} + + + + {report.status} + + + + + {report.createdAt.toLocaleString("en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + })}{" "} + UTC + +
+
+ +
+
+

Target ID

+ + {report.targetId} + +
+ +
+

Creator ID

+ + {report.creatorId} + +
+ + + +
+

Reason

+

{report.reason}

+
+
+ +
+

Notes

+

{report.reasonNotes}

+
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+ ))} +
+ + {reports.length === 0 && ( +
+

No reports to display

+

Reports will appear here when users submit them

+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ {total} total +
+ {page > 1 && ( + + Previous + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next + + )} +
+
+ )} +
+ ); +} diff --git a/backend/src/components/admin/user-management.tsx b/backend/src/components/admin/user-management.tsx new file mode 100644 index 0000000..acd986f --- /dev/null +++ b/backend/src/components/admin/user-management.tsx @@ -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(); + + const [type, setType] = useState("WARNING"); + const [duration, setDuration] = useState(1); + const [reason, setReason] = useState(""); + const [error, setError] = useState(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 ( +
+
+ setUserId(Number(e.target.value))} + className="pill input w-full max-w-lg" + /> + +
+ + {user && ( +
+
+
+ profile_picture +
+

{user.name}

+

@{user.name}

+

+ Created:{" "} + {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 +

+
+
+ +
+ +
+ {user.punishments.length === 0 ? ( +

No punishments found.

+ ) : ( + <> + {user.punishments.map((punishment) => ( +
+
+ + {punishment.type} + + +
+ + {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} + + +
+
+

+ Reason: {punishment.reason} +

+ {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)} +

+ )} +
+ ))} + + )} +
+
+ +
+ {/* Punishment type */} +

Punishment Type

+ + + {/* Punishment duration */} + {type === "TEMP_EXILE" && ( + <> +

Duration

+ + + )} + + {/* Punishment reason */} +

Reason

+