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) => (
-
- ))}
- {activePunishment.violatingMiis.map((mii) => (
-
-
-
-
{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"}
+
+
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"
+ >
+
+ Accept all ({miis.length})
+
+
+
+
+ {rows.map((row, rowIndex) => (
+
+
+ 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"
+ >
+
+ Accept row
+
+
+
+ {row.map((mii) => (
+
+ {mii.in_queue && (
+
+
+ In Queue
+
+ )}
+
+
+
+
+ {mii.name}
+
+
+ {mii.platform === "SWITCH" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {mii.tags.map((tag: string) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ @{mii.user?.name}
+
+
+
+
+ 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"
+ >
+
+
+
+
+
{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 (
+ <>
+ setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
+
+
+
+ {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} }
+
+
+
+ Cancel
+
+
+
+
+
,
+ 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) => (
+
+ 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}
+
+ ))}
+
+ );
+}
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
+
+
+
+
+
+
+
+
+
+
+
+
+
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"
+ />
+
+ Lookup User
+
+
+
+ {user && (
+
+
+
+
+
+
{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
+
setType(e.target.value as PunishmentType)} className="pill input">
+ Warning
+ Temporary Exile
+ Permanent Exile
+
+
+ {/* Punishment duration */}
+ {type === "TEMP_EXILE" && (
+ <>
+
Duration
+
setDuration(Number(e.target.value))} className="pill input">
+ 1 Day
+ 7 Days
+ 30 Days
+
+ >
+ )}
+
+ {/* Punishment reason */}
+
Reason
+
+
+ )}
+
+ );
+}
diff --git a/backend/src/components/pagination.tsx b/backend/src/components/pagination.tsx
new file mode 100644
index 0000000..aa5d452
--- /dev/null
+++ b/backend/src/components/pagination.tsx
@@ -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 (
+
+ {/* Go to first page */}
+
+
+
+
+ {/* Previous page */}
+
+
+
+
+ {/* Page numbers */}
+
+ {numbers.map((number) => (
+
+ {number}
+
+ ))}
+
+
+ {/* Next 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!"}`}
+ >
+
+
+
+ {/* Go to last 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!"}`}
+ >
+
+
+
+ );
+}
diff --git a/backend/src/components/submit-button.tsx b/backend/src/components/submit-button.tsx
new file mode 100644
index 0000000..c5067a7
--- /dev/null
+++ b/backend/src/components/submit-button.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useState } from "react";
+import { Icon } from "@iconify/react";
+
+interface Props {
+ onClick: () => void | Promise;
+ 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 (
+
+ {text}
+ {isLoading && }
+
+ );
+}
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
index c816b53..9d3b9b7 100644
--- a/backend/tsconfig.json
+++ b/backend/tsconfig.json
@@ -30,12 +30,10 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"../shared/src/constants.ts",
- "../frontend/src/lib/abbreviation.ts",
"../shared/src/qr-codes.ts",
"../shared/src/three-ds-tomodachi-life-mii.ts",
"../shared/src/types.d.ts",
"../shared/src/switch.ts",
- "../frontend/src/components/provider.tsx",
"../shared/src/schemas.ts"
],
"exclude": ["node_modules"]
diff --git a/frontend/src/components/admin/punishment-deletion-dialog.tsx b/frontend/src/components/admin/punishment-deletion-dialog.tsx
deleted file mode 100644
index a612d75..0000000
--- a/frontend/src/components/admin/punishment-deletion-dialog.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-// 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 (
-// <>
-// setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
-//
-//
-
-// {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} }
-
-//
-//
-// Cancel
-//
-//
-//
-//
-//
,
-// document.body,
-// )}
-// >
-// );
-// }
diff --git a/frontend/src/components/admin/queue.tsx b/frontend/src/components/admin/queue.tsx
deleted file mode 100644
index f58edb7..0000000
--- a/frontend/src/components/admin/queue.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-// import { Prisma } from "@prisma/client";
-// import { useMemo, useRef, useState } from "react";
-// import Carousel from "../carousel";
-// import Link from "next/link";
-// import { Icon } from "@iconify/react";
-
-// interface Props {
-// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
-// }
-
-// type Decision = "accept" | "reject" | null;
-
-// export default function Queue({ miis }: Props) {
-// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
-// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
-// const [decision, setDecision] = useState(null);
-// const [isAnimating, setIsAnimating] = useState(false);
-
-// const [dragOffset, setDragOffset] = useState(0);
-// const dragStart = useRef(null);
-// const isDragging = useRef(false);
-
-// const rotations = useMemo(() => {
-// const map: Record = {};
-// miis.forEach((mii) => {
-// map[mii.id] = Math.random() * 15 - 5;
-// });
-// return map;
-// }, [miis]);
-
-// const handleDecision = (decision: Decision) => {
-// if (isAnimating) return;
-// setDecision(decision);
-// setIsAnimating(true);
-// setDragOffset(decision === "accept" ? -300 : 300);
-
-// setTimeout(() => {
-// setVisibleMiis((prev) => {
-// const newQueue = prev.slice(1); // Remove first Mii
-// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
-// return newQueue;
-// });
-// setCurrentIndex((prev) => prev + 1);
-// setDecision(null);
-// setIsAnimating(false);
-// setDragOffset(0);
-// }, 500);
-// };
-
-// const onDragStart = (clientX: number) => {
-// if (isAnimating) return;
-// dragStart.current = clientX;
-// isDragging.current = true;
-// };
-
-// const onDragMove = (clientX: number) => {
-// if (!isDragging.current || !dragStart.current) return;
-// setDragOffset(clientX - dragStart.current);
-// };
-
-// const onDragEnd = () => {
-// if (!isDragging.current) return;
-// isDragging.current = false;
-
-// if (dragOffset < -80) handleDecision("accept");
-// else if (dragOffset > 80) handleDecision("reject");
-// else setDragOffset(0);
-
-// dragStart.current = null;
-// };
-
-// return (
-//
-//
handleDecision("accept")}
-// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
-// >
-//
-//
-
-//
-// {visibleMiis.map((mii, i) => {
-// const isTopCard = i === 0;
-
-// // Calculate rotation/opacity based on drag distance
-// const dragRotation = isTopCard ? dragOffset / 10 : 0;
-// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
-
-// return (
-//
isTopCard && onDragStart(e.clientX)}
-// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
-// onMouseUp={() => isTopCard && onDragEnd()}
-// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
-// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
-// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
-// onTouchEnd={() => isTopCard && onDragEnd()}
-// >
-//
`/mii/${mii.id}/image?type=image${index}`),
-// ]}
-// onlyButtons
-// />
-
-//
-//
-//
-// {mii.name}
-//
-//
-// {mii.platform === "SWITCH" ? (
-//
-// ) : (
-//
-// )}
-//
-//
-//
-// {mii.tags.map((tag) => (
-//
-// {tag}
-//
-// ))}
-//
-
-//
-//
{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}
-
-//
-// @{mii.user?.name}
-//
-//
-//
-//
-// );
-// })}
-//
-
-//
handleDecision("reject")}
-// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
-// >
-//
-//
-//
-// );
-// }
diff --git a/frontend/src/components/admin/report-tabs.tsx b/frontend/src/components/admin/report-tabs.tsx
deleted file mode 100644
index fe91007..0000000
--- a/frontend/src/components/admin/report-tabs.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// 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) => (
-//
-// 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}
-//
-// ))}
-//
-// );
-// }
diff --git a/frontend/src/components/admin/reports.tsx b/frontend/src/components/admin/reports.tsx
deleted file mode 100644
index 554c259..0000000
--- a/frontend/src/components/admin/reports.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-// 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
-//
-//
-//
-
-//
-//
-
-//
-
-//
-
-//
-//
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/frontend/src/components/admin/return-to-island.tsx b/frontend/src/components/admin/return-to-island.tsx
index 868d2ee..950a753 100644
--- a/frontend/src/components/admin/return-to-island.tsx
+++ b/frontend/src/components/admin/return-to-island.tsx
@@ -1,51 +1,50 @@
-// import { useState } from "react";
-// import { Icon } from "@iconify/react";
-// import { redirect } from "next/navigation";
+import { useState } from "react";
+import { Icon } from "@iconify/react";
+import { useNavigate } from "react-router";
-// interface Props {
-// hasExpired: boolean;
-// }
+interface Props {
+ hasExpired: boolean;
+}
-// export default function ReturnToIsland({ hasExpired }: Props) {
-// const [isChecked, setIsChecked] = useState(false);
-// const [error, setError] = useState(undefined);
+export default function ReturnToIsland({ hasExpired }: Props) {
+ const navigate = useNavigate();
+ const [isChecked, setIsChecked] = useState(false);
+ const [error, setError] = useState(undefined);
-// const handleClick = async () => {
-// const response = await fetch("/api/return", { method: "POST" });
+ const handleClick = async () => {
+ const response = await fetch(`${import.meta.env.VITE_API_URL}/api/return`, { method: "POST", credentials: "include" });
-// if (!response.ok) {
-// const data = await response.json();
-// setError(data.error);
+ if (!response.ok) {
+ const data = await response.json();
+ setError(data.error);
+ return;
+ }
+ navigate("/");
+ };
-// return;
-// }
+ return (
+ <>
+
+ setIsChecked(e.target.checked)}
+ className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
+ />
+
+ I Agree
+
+
-// redirect("/");
-// };
+
-// return (
-// <>
-//
-// setIsChecked(e.target.checked)}
-// className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
-// />
-//
-// I Agree
-//
-//
-
-//
-
-// {error && Error: {error} }
-//
-//
-// Travel Back
-//
-// >
-// );
-// }
+ {error && Error: {error} }
+
+
+ Travel Back
+
+ >
+ );
+}
diff --git a/frontend/src/components/admin/user-management.tsx b/frontend/src/components/admin/user-management.tsx
deleted file mode 100644
index 3672c19..0000000
--- a/frontend/src/components/admin/user-management.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-// // WARNING: this code is quite trash
-
-// import { useState } from "react";
-
-// import { Icon } from "@iconify/react";
-// import { Prisma, PunishmentType } from "@prisma/client";
-
-// import ProfilePicture from "../profile-picture";
-// import SubmitButton from "../submit-button";
-// import PunishmentDeletionDialog from "./punishment-deletion-dialog";
-
-// interface ApiResponse {
-// success: boolean;
-// name: string;
-// image: string;
-// createdAt: string;
-// punishments: Prisma.PunishmentGetPayload<{
-// include: {
-// violatingMiis: true;
-// };
-// }>[];
-// }
-
-// interface MiiList {
-// id: number;
-// reason: string;
-// }
-
-// export default function Punishments() {
-// const [userId, setUserId] = useState(-1);
-// const [user, setUser] = useState();
-
-// const [type, setType] = useState("WARNING");
-// const [duration, setDuration] = useState(1);
-// const [notes, setNotes] = useState("");
-// const [reasons, setReasons] = useState("");
-
-// const [miiList, setMiiList] = useState([]);
-// const [newMii, setNewMii] = useState({
-// id: 0,
-// reason: "",
-// });
-
-// const [error, setError] = useState(undefined);
-
-// const addMiiToList = () => {
-// if (newMii.id && newMii.reason) {
-// setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
-// setNewMii({ id: 0, reason: "" });
-// }
-// };
-
-// const removeMiiFromList = (index: number) => {
-// setMiiList(miiList.filter((_, i) => i !== index));
-// };
-
-// const handleLookup = async () => {
-// const response = await fetch(`/api/admin/lookup?id=${userId}`);
-// const data = await response.json();
-// setUser(data);
-// };
-
-// const handleSubmit = async () => {
-// const response = await fetch(`/api/admin/punish?id=${userId}`, {
-// method: "POST",
-// body: JSON.stringify({
-// type,
-// duration,
-// notes,
-// reasons: reasons.split(","),
-// miiReasons: miiList,
-// }),
-// });
-
-// if (!response.ok) {
-// const { error } = await response.json();
-// setError(error);
-// }
-
-// // Set all inputs to empty/default
-// setType("WARNING");
-// setDuration(1);
-// setNotes("");
-// setReasons("");
-// setMiiList([]);
-// setNewMii({ id: 0, reason: "" });
-// setError("");
-
-// await handleLookup();
-// };
-
-// return (
-//
-//
-// setUserId(Number(e.target.value))}
-// className="pill input w-full max-w-lg"
-// />
-//
-// Lookup User
-//
-//
-
-// {user && (
-//
-//
-//
-//
-//
-//
{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" })}
-//
-//
-//
-//
-//
-// Notes: {punishment.notes}
-//
-// {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:
-//
-//
-// {punishment.reasons.map((reason, index) => (
-// {reason}
-// ))}
-//
-//
-// Mii Reasons:
-//
-//
-// {punishment.violatingMiis.map((mii) => (
-//
-// {mii.miiId}: {mii.reason}
-//
-// ))}
-//
-//
-// ))}
-// >
-// )}
-//
-//
-
-//
-// {/* Punishment type */}
-//
Punishment Type
-//
setType(e.target.value as PunishmentType)} className="pill input">
-// Warning
-// Temporary Exile
-// Permanent Exile
-//
-
-// {/* Punishment duration */}
-// {type === "TEMP_EXILE" && (
-// <>
-//
Duration
-//
setDuration(Number(e.target.value))} className="pill input">
-// 1 Day
-// 7 Days
-// 30 Days
-//
-// >
-// )}
-
-// {/* Punishment notes */}
-//
Notes
-//
-//
-// )}
-//
-// );
-// }
diff --git a/frontend/src/layout.tsx b/frontend/src/layout.tsx
index 3c3d060..e316e6c 100644
--- a/frontend/src/layout.tsx
+++ b/frontend/src/layout.tsx
@@ -1,9 +1,18 @@
+import { useStore } from "@nanostores/react";
import AdminBanner from "./components/admin/banner";
import Footer from "./components/footer";
import Header from "./components/header";
import { useEffect } from "react";
+import { useLocation, useNavigate } from "react-router";
+import { session } from "./session";
export default function Layout({ children }: { children: React.ReactNode }) {
+ const $session = useStore(session);
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const API_URL = import.meta.env.VITE_API_URL;
+
// Calculate header height
useEffect(() => {
const header = document.querySelector("header");
@@ -25,6 +34,24 @@ export default function Layout({ children }: { children: React.ReactNode }) {
};
}, []);
+ // Check for punishment on every page navigation
+ useEffect(() => {
+ if (!$session) return;
+ if (["/punished", "/terms-of-service", "/privacy"].includes(location.pathname)) return;
+
+ fetch(`${API_URL}/api/is-punished`, { credentials: "include" })
+ .then((res) => {
+ if (!res.ok) return null;
+ return res.json();
+ })
+ .then((data) => {
+ if (data.isPunished) navigate("/punished", { replace: true });
+ })
+ .catch((err) => {
+ console.error("Failed to check punishment status:", err);
+ });
+ }, [$session, location.pathname]);
+
return (
<>
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 68b15dc..6df0358 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -21,8 +21,8 @@ import ProfileLayout from "./pages/profile/layout.tsx";
import ProfileLikesPage from "./pages/profile/likes.tsx";
import ReportMiiPage from "./pages/report/mii.tsx";
import ReportUserPage from "./pages/report/user.tsx";
-import AdminPage from "./pages/admin.tsx";
import EditMiiPage from "./pages/edit.tsx";
+import PunishedPage from "./pages/punished.tsx";
createRoot(document.getElementById("root")!).render(
@@ -47,7 +47,7 @@ createRoot(document.getElementById("root")!).render(
} />
} />
} />
- } />
+ } />
} />
diff --git a/frontend/src/pages/admin.tsx b/frontend/src/pages/admin.tsx
deleted file mode 100644
index 08254f1..0000000
--- a/frontend/src/pages/admin.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useStore } from "@nanostores/react";
-import MiiList from "../components/mii/list";
-import { session } from "../session";
-import { Navigate } from "react-router";
-import BannerForm from "../components/admin/banner-form";
-
-export default function AdminPage() {
- const $session = useStore(session);
- if ($session === undefined) return Loading...
;
- if ($session === null || Number($session?.user?.id) != import.meta.env.VITE_ADMIN_USER_ID) return ;
- return (
- <>
-
-
- >
- );
-}
diff --git a/frontend/src/pages/profile/layout.tsx b/frontend/src/pages/profile/layout.tsx
index 25b20a0..2847fe2 100644
--- a/frontend/src/pages/profile/layout.tsx
+++ b/frontend/src/pages/profile/layout.tsx
@@ -105,10 +105,10 @@ export default function ProfileLayout() {
)}
{isOwnProfile && isAdmin && (
-
+
Admin
-
+
)}
{isOwnProfile && page !== "/profile/likes" && (
diff --git a/frontend/src/pages/punished.tsx b/frontend/src/pages/punished.tsx
new file mode 100644
index 0000000..e2793e6
--- /dev/null
+++ b/frontend/src/pages/punished.tsx
@@ -0,0 +1,78 @@
+import { useStore } from "@nanostores/react";
+import dayjs from "dayjs";
+import { Link, Navigate } from "react-router";
+import { session } from "../session";
+import { useEffect, useState } from "react";
+import ReturnToIsland from "../components/admin/return-to-island";
+
+export default function PunishedPage() {
+ const $session = useStore(session);
+ const [activePunishment, setActivePunishment] = useState(undefined);
+
+ const API_URL = import.meta.env.VITE_API_URL;
+
+ useEffect(() => {
+ fetch(`${API_URL}/api/is-punished`, { credentials: "include" })
+ .then((res) => {
+ if (!res.ok) throw new Error("Failed to get punishment");
+ return res.json();
+ })
+ .then((data) => {
+ setActivePunishment(data.punishment);
+ })
+ .catch((err) => {
+ console.error(err);
+ setActivePunishment(null);
+ });
+ }, []);
+
+ if ($session === undefined || activePunishment === undefined) return Loading...
;
+ if ($session === null || !activePunishment) return ;
+
+ 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: {createdAt.toDate().toLocaleDateString("en-GB")} at {createdAt.toDate().toLocaleString("en-GB")}
+
+
+
+ Reason: {activePunishment.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.
+ >
+ )}
+
+
+ );
+}