mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
9 commits
7bd84ea454
...
d0fe60067a
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fe60067a | |||
| 70890a2ed5 | |||
| a01f88b088 | |||
| c41360eaad | |||
| ba03f0d4d9 | |||
| 11a7c8285b | |||
| 54fb2d1eec | |||
| f23602a45c | |||
| 77828653ba |
36 changed files with 1261 additions and 1090 deletions
|
|
@ -14,7 +14,7 @@ FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app /app
|
COPY --from=deps /app /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN cd backend && pnpm build
|
RUN cd backend && pnpm prisma migrate deploy && pnpm build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
|
|
@ -28,8 +28,10 @@ ENV HOSTNAME=0.0.0.0
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# I know all the paths are messed up but I don't have time to fix it
|
||||||
COPY --from=builder /app/backend/public ./public
|
COPY --from=builder /app/backend/public ./public
|
||||||
COPY --from=builder /app/backend/.next ./.next
|
COPY --from=builder /app/backend/.next ./.next
|
||||||
|
COPY --from=builder /app/backend/.next/static ./.next/standalone/backend/.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma
|
||||||
|
|
||||||
RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
|
RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -90,12 +90,9 @@ model Mii {
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
likeCount Int @default(0)
|
likeCount Int @default(0)
|
||||||
|
likedBy Like[]
|
||||||
punishmentId Int?
|
|
||||||
punishments MiiPunishment[]
|
|
||||||
likedBy Like[]
|
|
||||||
|
|
||||||
@@index([tags], type: Gin)
|
@@index([tags], type: Gin)
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
|
@ -142,28 +139,13 @@ model Report {
|
||||||
@@map("reports")
|
@@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 {
|
model Punishment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
type PunishmentType
|
type PunishmentType
|
||||||
returned Boolean @default(false)
|
returned Boolean @default(false)
|
||||||
|
|
||||||
notes String
|
reason String
|
||||||
reasons String[]
|
|
||||||
violatingMiis MiiPunishment[]
|
|
||||||
|
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
|
||||||
74
backend/src/app/admin/page.tsx
Normal file
74
backend/src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin - TomodachiShare",
|
||||||
|
description: "TomodachiShare admin panel",
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPage({ searchParams }: Props) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session || Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Admin Panel</h2>
|
||||||
|
<p className="text-sm text-zinc-500">View reports, set banners, etc.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Banners</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BannerForm />
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>User Management</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserManagement />
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Reports</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reports searchParams={await searchParams} />
|
||||||
|
|
||||||
|
{/* Queue */}
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
<span>Queue</span>
|
||||||
|
<hr className="grow border-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<MiiList searchParams={await searchParams} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,16 +29,7 @@ export async function GET(request: NextRequest) {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
returned: true,
|
returned: true,
|
||||||
|
reason: true,
|
||||||
notes: true,
|
|
||||||
reasons: true,
|
|
||||||
violatingMiis: {
|
|
||||||
select: {
|
|
||||||
miiId: true,
|
|
||||||
reason: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
expiresAt: true,
|
expiresAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,7 @@ const punishSchema = z.object({
|
||||||
.number({ error: "Duration (days) must be a number" })
|
.number({ error: "Duration (days) must be a number" })
|
||||||
.int({ error: "Duration (days) must be an integer" })
|
.int({ error: "Duration (days) must be an integer" })
|
||||||
.positive({ error: "Duration (days) must be valid" }),
|
.positive({ error: "Duration (days) must be valid" }),
|
||||||
notes: z.string(),
|
reason: 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(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -42,7 +33,7 @@ export async function POST(request: NextRequest) {
|
||||||
const parsed = punishSchema.safeParse(body);
|
const parsed = punishSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
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;
|
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
|
||||||
|
|
||||||
|
|
@ -51,14 +42,7 @@ export async function POST(request: NextRequest) {
|
||||||
userId,
|
userId,
|
||||||
type: type as PunishmentType,
|
type: type as PunishmentType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
notes,
|
reason,
|
||||||
reasons: reasons?.length !== 0 ? reasons : [],
|
|
||||||
violatingMiis: {
|
|
||||||
create: miiReasons?.map((mii) => ({
|
|
||||||
miiId: mii.id,
|
|
||||||
reason: mii.reason,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
24
backend/src/app/api/is-punished/route.ts
Normal file
24
backend/src/app/api/is-punished/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const rateLimit = new RateLimit(request, 30);
|
||||||
|
const check = await rateLimit.handle();
|
||||||
|
if (check) return check;
|
||||||
|
|
||||||
|
const activePunishment = await prisma.punishment.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: Number(session.user?.id),
|
||||||
|
returned: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activePunishment) return rateLimit.sendResponse({ isPunished: false, punishment: null });
|
||||||
|
return rateLimit.sendResponse({ isPunished: true, punishment: activePunishment });
|
||||||
|
}
|
||||||
|
|
@ -17,17 +17,6 @@ export async function POST(request: NextRequest) {
|
||||||
userId: Number(session.user?.id),
|
userId: Number(session.user?.id),
|
||||||
returned: false,
|
returned: false,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
violatingMiis: {
|
|
||||||
include: {
|
|
||||||
mii: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);
|
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);
|
||||||
|
|
|
||||||
90
backend/src/app/globals.css
Normal file
90
backend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply hover:bg-orange-400 transition cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
@apply flex items-center justify-center appearance-none size-5 bg-orange-300 border-2 border-orange-400 rounded-md cursor-pointer checked:bg-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox::after {
|
||||||
|
@apply hidden size-4 bg-cover bg-no-repeat content-[''];
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 13l4 4L19 7' /%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox:checked::after {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-alt {
|
||||||
|
@apply relative appearance-none bg-zinc-400 rounded-2xl h-5 w-8.5 cursor-pointer transition-all
|
||||||
|
after:transition-all after:bg-zinc-100 after:rounded-full after:h-3.5 after:absolute after:w-3.5
|
||||||
|
after:left-[3px] after:top-[3px] hover:bg-zinc-500 checked:bg-orange-400 checked:after:left-[16px]
|
||||||
|
checked:hover:bg-orange-500 ml-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip] {
|
||||||
|
@apply relative z-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::before {
|
||||||
|
@apply content-[''] absolute left-1/2 -translate-x-1/2 top-full size-0 border-4 border-transparent border-b-orange-400 opacity-0 scale-75 transition-all duration-200 ease-out origin-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::after {
|
||||||
|
@apply content-[attr(data-tooltip)] absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white opacity-0 scale-75 transition-all duration-200 ease-out origin-top shadow-md whitespace-nowrap select-none pointer-events-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::before,
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
@apply opacity-100 scale-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback Tooltips */
|
||||||
|
[data-tooltip-span] {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span] > .tooltip {
|
||||||
|
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span] > .tooltip::before {
|
||||||
|
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span]:hover > .tooltip {
|
||||||
|
@apply opacity-100 scale-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-amber-50 text-slate-800 min-h-screen;
|
||||||
|
font-family: "Lexend Variable", sans-serif;
|
||||||
|
|
||||||
|
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
|
||||||
|
background-image: url('data:image/svg+xml;utf8,\
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">\
|
||||||
|
<rect width="10" height="10" fill="%23fef3c6"/>\
|
||||||
|
<rect x="10" y="10" width="10" height="10" fill="%23fef3c6"/>\
|
||||||
|
<rect x="10" width="10" height="10" fill="%23fffbeb"/>\
|
||||||
|
<rect y="10" width="10" height="10" fill="%23fffbeb"/>\
|
||||||
|
</svg>');
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
16
backend/src/app/layout.tsx
Normal file
16
backend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>TomodachiShare API</title>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
// import ReturnToIsland from "@/components/admin/return-to-island";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Exiled - TomodachiShare",
|
|
||||||
description: "You have been exiled from the TomodachiShare island...",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ExiledPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user) redirect("/");
|
|
||||||
|
|
||||||
const activePunishment = await prisma.punishment.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: Number(session?.user.id),
|
|
||||||
returned: false,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
violatingMiis: {
|
|
||||||
include: {
|
|
||||||
mii: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!activePunishment) redirect("/");
|
|
||||||
|
|
||||||
const expiresAt = dayjs(activePunishment.expiresAt);
|
|
||||||
const createdAt = dayjs(activePunishment.createdAt);
|
|
||||||
|
|
||||||
const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date();
|
|
||||||
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grow flex items-center justify-center">
|
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
|
|
||||||
<h2 className="text-4xl font-black mb-2">
|
|
||||||
{activePunishment.type === "PERM_EXILE"
|
|
||||||
? "Exiled permanently"
|
|
||||||
: activePunishment.type === "TEMP_EXILE"
|
|
||||||
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
|
|
||||||
: "Warning"}
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
You have been exiled from the TomodachiShare island because you violated the{" "}
|
|
||||||
<Link href={"/terms-of-service"} className="text-blue-500">
|
|
||||||
Terms of Service
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-3">
|
|
||||||
<span className="font-bold">Reviewed:</span> {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "}
|
|
||||||
{activePunishment.createdAt.toLocaleString("en-GB")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-1">
|
|
||||||
<span className="font-bold">Note:</span> {activePunishment.notes}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
|
|
||||||
<hr className="grow border-zinc-300" />
|
|
||||||
<span>Violating Items</span>
|
|
||||||
<hr className="grow border-zinc-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 p-4">
|
|
||||||
{activePunishment.reasons.map((index, reason) => (
|
|
||||||
<div key={index} className="bg-orange-100 rounded-xl border-2 border-orange-400 p-4">
|
|
||||||
<p>
|
|
||||||
<span className="font-bold">Reason:</span> {reason}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{activePunishment.violatingMiis.map((mii) => (
|
|
||||||
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
|
|
||||||
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="font-bold">Reason:</span> {mii.reason}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="border-zinc-300 mt-2 mb-4" />
|
|
||||||
|
|
||||||
{activePunishment.type !== "PERM_EXILE" ? (
|
|
||||||
<>
|
|
||||||
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
|
|
||||||
{/* <ReturnToIsland hasExpired={hasExpired} /> */}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>Your punishment is permanent, therefore you cannot return.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
return (
|
return <p>TomodachiShare API</p>;
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<p>TomodachiShare API</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,27 @@ type SitemapRoute = MetadataRoute.Sitemap[0];
|
||||||
export const revalidate = 43200; // update every 12 hours
|
export const revalidate = 43200; // update every 12 hours
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
const baseUrl = process.env.NEXT_PUBLIC_FRONTEND_URL!;
|
||||||
if (!baseUrl) {
|
const apiUrl = process.env.NEXT_PUBLIC_BASE_URL!;
|
||||||
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const miis = await prisma.mii.findMany({
|
const miis = await prisma.mii.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
where: {
|
||||||
|
in_queue: false,
|
||||||
const users = await prisma.user.findMany({
|
quarantined: false,
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// const users = await prisma.user.findMany({
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// updatedAt: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
const dynamicRoutes: MetadataRoute.Sitemap = [
|
const dynamicRoutes: MetadataRoute.Sitemap = [
|
||||||
...miis.map(
|
...miis.map(
|
||||||
(mii) =>
|
(mii) =>
|
||||||
|
|
@ -34,18 +35,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
lastModified: mii.createdAt,
|
lastModified: mii.createdAt,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
|
images: [`${apiUrl}/mii/${mii.id}/image?type=metadata`],
|
||||||
}) as SitemapRoute,
|
|
||||||
),
|
|
||||||
...users.map(
|
|
||||||
(user) =>
|
|
||||||
({
|
|
||||||
url: `${baseUrl}/profile/${user.id}`,
|
|
||||||
lastModified: user.updatedAt,
|
|
||||||
changeFrequency: "weekly",
|
|
||||||
priority: 0.2,
|
|
||||||
}) as SitemapRoute,
|
}) as SitemapRoute,
|
||||||
),
|
),
|
||||||
|
// ...users.map(
|
||||||
|
// (user) =>
|
||||||
|
// ({
|
||||||
|
// url: `${baseUrl}/profile/${user.id}`,
|
||||||
|
// lastModified: user.updatedAt,
|
||||||
|
// changeFrequency: "weekly",
|
||||||
|
// priority: 0.2,
|
||||||
|
// }) as SitemapRoute,
|
||||||
|
// ),
|
||||||
];
|
];
|
||||||
|
|
||||||
const lastModified = new Date();
|
const lastModified = new Date();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function BannerForm() {
|
export default function BannerForm() {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
const onClickClear = async () => {
|
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 () => {
|
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 (
|
return (
|
||||||
137
backend/src/components/admin/mii-grid.tsx
Normal file
137
backend/src/components/admin/mii-grid.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"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" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptMany = async (ids: number[]) => {
|
||||||
|
await Promise.all(ids.map((id) => fetch(`/api/admin/accept-mii?id=${id}`, { method: "POST" })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: (typeof miis)[] = [];
|
||||||
|
for (let i = 0; i < miis.length; i += 4) rows.push(miis.slice(i, i + 4));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
||||||
|
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:check-circle-rounded" />
|
||||||
|
Accept all ({miis.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:check-circle-outline-rounded" />
|
||||||
|
Accept row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||||
|
{row.map((mii) => (
|
||||||
|
<div
|
||||||
|
key={mii.id}
|
||||||
|
className={`flex flex-col relative bg-zinc-50 border-zinc-300 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300 bg-red-50!" : ""}`}
|
||||||
|
>
|
||||||
|
{mii.in_queue && (
|
||||||
|
<div className="absolute top-2 left-2 z-10 bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1">
|
||||||
|
<Icon icon="mdi:clock-outline" className="text-base" />
|
||||||
|
In Queue
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200">
|
||||||
|
{[
|
||||||
|
`/mii/${mii.id}/image?type=mii`,
|
||||||
|
mii.platform === "THREE_DS" ? `/mii/${mii.id}/image?type=qr-code` : `/mii/${mii.id}/image?type=features`,
|
||||||
|
...Array.from({ length: mii.imageCount }, (_, i) => `/mii/${mii.id}/image?type=image${i}`),
|
||||||
|
].map((src, i) => (
|
||||||
|
<img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col gap-1 h-full">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link
|
||||||
|
href={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${mii.id}`}
|
||||||
|
className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
|
||||||
|
title={mii.name}
|
||||||
|
>
|
||||||
|
{mii.name}
|
||||||
|
</Link>
|
||||||
|
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
||||||
|
{mii.platform === "SWITCH" ? (
|
||||||
|
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tags" className="flex flex-wrap gap-1">
|
||||||
|
{mii.tags.map((tag: string) => (
|
||||||
|
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto grid grid-cols-2 items-center">
|
||||||
|
<Link
|
||||||
|
href={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/profile/${mii.user?.id}`}
|
||||||
|
className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
|
@{mii.user?.name}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex justify-between w-full col-span-2 mt-2">
|
||||||
|
<div className="flex gap-1 text-3xl justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:check-rounded" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm w-1/2 text-right">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination lastPage={lastPage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
backend/src/components/admin/mii-list.tsx
Normal file
35
backend/src/components/admin/mii-list.tsx
Normal file
|
|
@ -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 <h1>{parsed.error.issues[0].message}</h1>;
|
||||||
|
|
||||||
|
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 <MiiGrid miis={miis} totalCount={totalCount} lastPage={lastPage} />;
|
||||||
|
}
|
||||||
94
backend/src/components/admin/punishment-deletion-dialog.tsx
Normal file
94
backend/src/components/admin/punishment-deletion-dialog.tsx
Normal file
|
|
@ -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<string | undefined>(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 (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
|
||||||
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||||
|
<div
|
||||||
|
onClick={close}
|
||||||
|
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||||
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
||||||
|
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h2 className="text-xl font-bold">Punishment Deletion</h2>
|
||||||
|
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||||
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-zinc-500">Are you sure? This will delete the user‘s punishment and they will be able to come back.</p>
|
||||||
|
|
||||||
|
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<button onClick={close} className="pill button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<SubmitButton onClick={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import SubmitButton from "../submit-button";
|
import SubmitButton from "../../../../frontend/src/components/submit-button";
|
||||||
|
|
||||||
export default function RegenerateImagesButton() {
|
export default function RegenerateImagesButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
32
backend/src/components/admin/report-tabs.tsx
Normal file
32
backend/src/components/admin/report-tabs.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
|
||||||
|
{["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() =>
|
||||||
|
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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
backend/src/components/admin/reports.tsx
Normal file
202
backend/src/components/admin/reports.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
||||||
|
<ReportTabs status={status} />
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
||||||
|
<div className="w-full overflow-x-scroll">
|
||||||
|
<div className="flex gap-1 w-max">
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||||
|
report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{report.reportType}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||||
|
report.status == "OPEN"
|
||||||
|
? "bg-orange-200 text-orange-800 border-orange-400"
|
||||||
|
: report.status == "RESOLVED"
|
||||||
|
? "bg-green-200 text-green-800 border-green-400"
|
||||||
|
: "bg-zinc-200 text-zinc-800 border-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{report.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
|
||||||
|
<Icon icon="lucide:calendar" className="text-base" />
|
||||||
|
{report.createdAt.toLocaleString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
})}{" "}
|
||||||
|
UTC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p>Target ID</p>
|
||||||
|
<a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
||||||
|
{report.targetId}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>Creator ID</p>
|
||||||
|
<a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
||||||
|
{report.creatorId}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>Reporter</p>
|
||||||
|
<a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
||||||
|
{report.authorId}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>Reason</p>
|
||||||
|
<p className="font-medium text-black text-sm">{report.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
|
||||||
|
<p className="text-zinc-600 text-xs">Notes</p>
|
||||||
|
<p>{report.reasonNotes}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
<form action={updateStatus}>
|
||||||
|
<input type="hidden" name="id" value={report.id} />
|
||||||
|
<input type="hidden" name="status" value={"OPEN"} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Open"
|
||||||
|
className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:alert-circle" className="text-xl" />
|
||||||
|
<span className="text-sm">Open</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action={updateStatus}>
|
||||||
|
<input type="hidden" name="id" value={report.id} />
|
||||||
|
<input type="hidden" name="status" value={"RESOLVED"} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Resolve"
|
||||||
|
className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:check-circle" className="text-xl" />
|
||||||
|
<span className="text-sm">Resolve</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action={updateStatus}>
|
||||||
|
<input type="hidden" name="id" value={report.id} />
|
||||||
|
<input type="hidden" name="status" value={"DISMISSED"} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close-circle" className="text-xl" />
|
||||||
|
<span className="text-sm">Dismiss</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reports.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p className="text-lg font-medium">No reports to display</p>
|
||||||
|
<p className="text-sm">Reports will appear here when users submit them</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-between items-center p-3 border-t border-orange-300">
|
||||||
|
<span className="text-sm text-orange-700">{total} total</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{page > 1 && (
|
||||||
|
<a
|
||||||
|
href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
|
||||||
|
className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-orange-700">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages && (
|
||||||
|
<a
|
||||||
|
href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
|
||||||
|
className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
backend/src/components/admin/user-management.tsx
Normal file
207
backend/src/components/admin/user-management.tsx
Normal file
|
|
@ -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<ApiResponse | undefined>();
|
||||||
|
|
||||||
|
const [type, setType] = useState<PunishmentType>("WARNING");
|
||||||
|
const [duration, setDuration] = useState(1);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [error, setError] = useState<string | undefined>(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 (
|
||||||
|
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
|
||||||
|
<div className="flex justify-center items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter user ID to lookup..."
|
||||||
|
name="user-id"
|
||||||
|
value={userId !== -1 ? userId : ""}
|
||||||
|
onChange={(e) => setUserId(Number(e.target.value))}
|
||||||
|
className="pill input w-full max-w-lg"
|
||||||
|
/>
|
||||||
|
<button onClick={handleLookup} className="pill button">
|
||||||
|
Lookup User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
|
||||||
|
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<img src={user.image} width={96} height={96} alt="profile_picture" className="rounded-full border-2 border-orange-400" />
|
||||||
|
<div className="p-2 flex flex-col">
|
||||||
|
<p className="text-xl font-bold">{user.name}</p>
|
||||||
|
<p className="text-black/60 text-sm font-medium">@{user.name}</p>
|
||||||
|
<p className="text-sm mt-auto">
|
||||||
|
<span className="font-medium">Created:</span>{" "}
|
||||||
|
{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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-zinc-300 my-3" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{user.punishments.length === 0 ? (
|
||||||
|
<p className="text-center text-zinc-500 my-2">No punishments found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{user.punishments.map((punishment) => (
|
||||||
|
<div
|
||||||
|
key={punishment.id}
|
||||||
|
className={`border rounded-lg p-3 space-y-1 ${
|
||||||
|
punishment.type === "WARNING"
|
||||||
|
? "bg-yellow-50 border-yellow-400"
|
||||||
|
: punishment.type === "TEMP_EXILE"
|
||||||
|
? "bg-orange-100 border-orange-200"
|
||||||
|
: "bg-red-50 border-red-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span
|
||||||
|
className={`border px-2 py-1 rounded text-xs font-semibold ${
|
||||||
|
punishment.type === "WARNING"
|
||||||
|
? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
||||||
|
: punishment.type === "TEMP_EXILE"
|
||||||
|
? "bg-orange-200 text-orange-800 border-orange-500"
|
||||||
|
: "bg-red-200 text-red-800 border-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{punishment.type}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-zinc-600">
|
||||||
|
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
|
||||||
|
</span>
|
||||||
|
<PunishmentDeletionDialog punishmentId={punishment.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-600">
|
||||||
|
<strong>Reason:</strong> {punishment.reason}
|
||||||
|
</p>
|
||||||
|
{punishment.type !== "WARNING" && (
|
||||||
|
<p className="text-sm text-zinc-600">
|
||||||
|
<strong>Expires:</strong>{" "}
|
||||||
|
{punishment.expiresAt
|
||||||
|
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
||||||
|
: "Never"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{punishment.type !== "PERM_EXILE" && (
|
||||||
|
<p className="text-sm text-zinc-600">
|
||||||
|
<strong>Returned:</strong> {JSON.stringify(punishment.returned)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
|
||||||
|
{/* Punishment type */}
|
||||||
|
<p className="text-sm">Punishment Type</p>
|
||||||
|
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
|
||||||
|
<option value="WARNING">Warning</option>
|
||||||
|
<option value="TEMP_EXILE">Temporary Exile</option>
|
||||||
|
<option value="PERM_EXILE">Permanent Exile</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Punishment duration */}
|
||||||
|
{type === "TEMP_EXILE" && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">Duration</p>
|
||||||
|
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
|
||||||
|
<option value="1">1 Day</option>
|
||||||
|
<option value="7">7 Days</option>
|
||||||
|
<option value="30">30 Days</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Punishment reason */}
|
||||||
|
<p className="text-sm">Reason</p>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
maxLength={256}
|
||||||
|
placeholder="Type the reason here for the punishment..."
|
||||||
|
className="pill input rounded-xl! resize-none"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||||
|
|
||||||
|
<SubmitButton onClick={handleSubmit} className="ml-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
backend/src/components/pagination.tsx
Normal file
101
backend/src/components/pagination.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex justify-center items-center w-full mt-8">
|
||||||
|
{/* Go to first page */}
|
||||||
|
<Link
|
||||||
|
href={page === 1 ? "#" : createPageUrl(1)}
|
||||||
|
aria-label="Go to First Page"
|
||||||
|
aria-disabled={page === 1}
|
||||||
|
tabIndex={page === 1 ? -1 : undefined}
|
||||||
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
|
>
|
||||||
|
<Icon icon="stash:chevron-double-left" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Previous page */}
|
||||||
|
<Link
|
||||||
|
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||||
|
aria-label="Go to Previous Page"
|
||||||
|
aria-disabled={page === 1}
|
||||||
|
tabIndex={page === 1 ? -1 : undefined}
|
||||||
|
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
|
>
|
||||||
|
<Icon icon="stash:chevron-left" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
<div className="flex mx-2">
|
||||||
|
{numbers.map((number) => (
|
||||||
|
<Link
|
||||||
|
key={number}
|
||||||
|
href={createPageUrl(number)}
|
||||||
|
aria-label={`Go to Page ${number}`}
|
||||||
|
aria-current={number === page ? "page" : undefined}
|
||||||
|
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next page */}
|
||||||
|
<Link
|
||||||
|
href={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!"}`}
|
||||||
|
>
|
||||||
|
<Icon icon="stash:chevron-right" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Go to last page */}
|
||||||
|
<Link
|
||||||
|
href={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!"}`}
|
||||||
|
>
|
||||||
|
<Icon icon="stash:chevron-double-right" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
backend/src/components/submit-button.tsx
Normal file
33
backend/src/components/submit-button.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
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 (
|
||||||
|
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
|
||||||
|
{text}
|
||||||
|
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,12 +30,10 @@
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"../shared/src/constants.ts",
|
"../shared/src/constants.ts",
|
||||||
"../frontend/src/lib/abbreviation.ts",
|
|
||||||
"../shared/src/qr-codes.ts",
|
"../shared/src/qr-codes.ts",
|
||||||
"../shared/src/three-ds-tomodachi-life-mii.ts",
|
"../shared/src/three-ds-tomodachi-life-mii.ts",
|
||||||
"../shared/src/types.d.ts",
|
"../shared/src/types.d.ts",
|
||||||
"../shared/src/switch.ts",
|
"../shared/src/switch.ts",
|
||||||
"../frontend/src/components/provider.tsx",
|
|
||||||
"../shared/src/schemas.ts"
|
"../shared/src/schemas.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|
|
||||||
|
|
@ -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<string | undefined>(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 (
|
|
||||||
// <>
|
|
||||||
// <button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
|
|
||||||
// <Icon icon="material-symbols:close-rounded" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// {isOpen &&
|
|
||||||
// createPortal(
|
|
||||||
// <div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
|
||||||
// <div
|
|
||||||
// onClick={close}
|
|
||||||
// className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
|
||||||
// isVisible ? "opacity-100" : "opacity-0"
|
|
||||||
// }`}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <div
|
|
||||||
// className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
|
||||||
// isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <div className="flex justify-between items-center mb-2">
|
|
||||||
// <h2 className="text-xl font-bold">Punishment Deletion</h2>
|
|
||||||
// <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
|
||||||
// <Icon icon="material-symbols:close-rounded" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <p className="text-sm text-zinc-500">Are you sure? This will delete the user‘s punishment and they will be able to come back.</p>
|
|
||||||
|
|
||||||
// {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
|
||||||
|
|
||||||
// <div className="flex justify-end gap-2 mt-4">
|
|
||||||
// <button onClick={close} className="pill button">
|
|
||||||
// Cancel
|
|
||||||
// </button>
|
|
||||||
// <SubmitButton onClick={handleSubmit} />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>,
|
|
||||||
// document.body,
|
|
||||||
// )}
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -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<Decision>(null);
|
|
||||||
// const [isAnimating, setIsAnimating] = useState(false);
|
|
||||||
|
|
||||||
// const [dragOffset, setDragOffset] = useState(0);
|
|
||||||
// const dragStart = useRef<number | null>(null);
|
|
||||||
// const isDragging = useRef(false);
|
|
||||||
|
|
||||||
// const rotations = useMemo(() => {
|
|
||||||
// const map: Record<string, number> = {};
|
|
||||||
// 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 (
|
|
||||||
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
|
|
||||||
// <button
|
|
||||||
// onClick={() => 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"
|
|
||||||
// >
|
|
||||||
// <Icon icon="material-symbols:check-rounded" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// <div className="relative w-full max-w-96 h-96 aspect-square">
|
|
||||||
// {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 (
|
|
||||||
// <div
|
|
||||||
// key={mii.id}
|
|
||||||
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
|
|
||||||
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
|
|
||||||
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
|
||||||
// style={{
|
|
||||||
// transform: isTopCard
|
|
||||||
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
|
|
||||||
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
|
|
||||||
// zIndex: (visibleMiis.length - i) * 10,
|
|
||||||
// opacity: dragOpacity,
|
|
||||||
// }}
|
|
||||||
// onMouseDown={(e) => 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()}
|
|
||||||
// >
|
|
||||||
// <Carousel
|
|
||||||
// images={[
|
|
||||||
// `/mii/${mii.id}/image?type=mii`,
|
|
||||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
|
|
||||||
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
|
||||||
// ]}
|
|
||||||
// onlyButtons
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
|
||||||
// <div className="flex justify-between items-center">
|
|
||||||
// <Link
|
|
||||||
// href={`/mii/${mii.id}`}
|
|
||||||
// draggable={false}
|
|
||||||
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
|
|
||||||
// title={mii.name}
|
|
||||||
// >
|
|
||||||
// {mii.name}
|
|
||||||
// </Link>
|
|
||||||
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
|
||||||
// {mii.platform === "SWITCH" ? (
|
|
||||||
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
|
|
||||||
// ) : (
|
|
||||||
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <div id="tags" className="flex flex-wrap gap-1">
|
|
||||||
// {mii.tags.map((tag) => (
|
|
||||||
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
|
||||||
// {tag}
|
|
||||||
// </Link>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
|
|
||||||
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
|
|
||||||
|
|
||||||
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
// @{mii.user?.name}
|
|
||||||
// </Link>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// onClick={() => 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"
|
|
||||||
// >
|
|
||||||
// <Icon icon="material-symbols:close-rounded" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -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 (
|
|
||||||
// <div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
|
|
||||||
// {["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
|
|
||||||
// <button
|
|
||||||
// key={s}
|
|
||||||
// onClick={() =>
|
|
||||||
// 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}
|
|
||||||
// </button>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -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 (
|
|
||||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
|
||||||
// <ReportTabs status={status} />
|
|
||||||
|
|
||||||
// {/* Grid */}
|
|
||||||
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
|
||||||
// {reports.map((report) => (
|
|
||||||
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
|
||||||
// <div className="w-full overflow-x-scroll">
|
|
||||||
// <div className="flex gap-1 w-max">
|
|
||||||
// <span
|
|
||||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
|
||||||
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// {report.reportType}
|
|
||||||
// </span>
|
|
||||||
|
|
||||||
// <span
|
|
||||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
|
||||||
// report.status == "OPEN"
|
|
||||||
// ? "bg-orange-200 text-orange-800 border-orange-400"
|
|
||||||
// : report.status == "RESOLVED"
|
|
||||||
// ? "bg-green-200 text-green-800 border-green-400"
|
|
||||||
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// {report.status}
|
|
||||||
// </span>
|
|
||||||
|
|
||||||
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
|
|
||||||
// <Icon icon="lucide:calendar" className="text-base" />
|
|
||||||
// {report.createdAt.toLocaleString("en-GB", {
|
|
||||||
// day: "2-digit",
|
|
||||||
// month: "long",
|
|
||||||
// year: "numeric",
|
|
||||||
// hour: "2-digit",
|
|
||||||
// minute: "2-digit",
|
|
||||||
// second: "2-digit",
|
|
||||||
// timeZone: "UTC",
|
|
||||||
// })}{" "}
|
|
||||||
// UTC
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
|
||||||
// <div>
|
|
||||||
// <p>Target ID</p>
|
|
||||||
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.targetId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Creator ID</p>
|
|
||||||
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.creatorId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Reporter</p>
|
|
||||||
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.authorId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Reason</p>
|
|
||||||
// <p className="font-medium text-black text-sm">{report.reason}</p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
|
|
||||||
// <p className="text-zinc-600 text-xs">Notes</p>
|
|
||||||
// <p>{report.reasonNotes}</p>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-4 flex gap-4">
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"OPEN"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Open"
|
|
||||||
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:alert-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Open</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"RESOLVED"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Resolve"
|
|
||||||
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:check-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Resolve</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"DISMISSED"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Dismiss"
|
|
||||||
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:close-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Dismiss</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {reports.length === 0 && (
|
|
||||||
// <div className="text-center py-12 text-gray-500">
|
|
||||||
// <p className="text-lg font-medium">No reports to display</p>
|
|
||||||
// <p className="text-sm">Reports will appear here when users submit them</p>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {/* Pagination */}
|
|
||||||
// {totalPages > 1 && (
|
|
||||||
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
|
|
||||||
// <span className="text-sm text-orange-700">{total} total</span>
|
|
||||||
// <div className="flex items-center gap-3">
|
|
||||||
// {page > 1 && (
|
|
||||||
// <a
|
|
||||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
|
|
||||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
// >
|
|
||||||
// Previous
|
|
||||||
// </a>
|
|
||||||
// )}
|
|
||||||
// <span className="text-sm text-orange-700">
|
|
||||||
// Page {page} of {totalPages}
|
|
||||||
// </span>
|
|
||||||
// {page < totalPages && (
|
|
||||||
// <a
|
|
||||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
|
|
||||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
// >
|
|
||||||
// Next
|
|
||||||
// </a>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,51 +1,50 @@
|
||||||
// import { useState } from "react";
|
import { useState } from "react";
|
||||||
// import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
// import { redirect } from "next/navigation";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
// interface Props {
|
interface Props {
|
||||||
// hasExpired: boolean;
|
hasExpired: boolean;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// export default function ReturnToIsland({ hasExpired }: Props) {
|
export default function ReturnToIsland({ hasExpired }: Props) {
|
||||||
// const [isChecked, setIsChecked] = useState(false);
|
const navigate = useNavigate();
|
||||||
// const [error, setError] = useState<string | undefined>(undefined);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
// const response = await fetch("/api/return", { method: "POST" });
|
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/return`, { method: "POST", credentials: "include" });
|
||||||
|
|
||||||
// if (!response.ok) {
|
if (!response.ok) {
|
||||||
// const data = await response.json();
|
const data = await response.json();
|
||||||
// setError(data.error);
|
setError(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
// return;
|
return (
|
||||||
// }
|
<>
|
||||||
|
<div className="flex justify-center items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="agreement"
|
||||||
|
disabled={hasExpired}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => setIsChecked(e.target.checked)}
|
||||||
|
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
|
||||||
|
/>
|
||||||
|
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
|
||||||
|
I Agree
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
// redirect("/");
|
<hr className="border-zinc-300 mt-3 mb-4" />
|
||||||
// };
|
|
||||||
|
|
||||||
// return (
|
{error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>}
|
||||||
// <>
|
<button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center">
|
||||||
// <div className="flex justify-center items-center gap-2">
|
<Icon icon="ic:round-home" fontSize={24} />
|
||||||
// <input
|
Travel Back
|
||||||
// type="checkbox"
|
</button>
|
||||||
// id="agreement"
|
</>
|
||||||
// disabled={hasExpired}
|
);
|
||||||
// checked={isChecked}
|
}
|
||||||
// onChange={(e) => setIsChecked(e.target.checked)}
|
|
||||||
// className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
|
|
||||||
// />
|
|
||||||
// <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
|
|
||||||
// I Agree
|
|
||||||
// </label>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <hr className="border-zinc-300 mt-3 mb-4" />
|
|
||||||
|
|
||||||
// {error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>}
|
|
||||||
// <button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center">
|
|
||||||
// <Icon icon="ic:round-home" fontSize={24} />
|
|
||||||
// Travel Back
|
|
||||||
// </button>
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
|
||||||
|
|
@ -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<ApiResponse | undefined>();
|
|
||||||
|
|
||||||
// const [type, setType] = useState<PunishmentType>("WARNING");
|
|
||||||
// const [duration, setDuration] = useState(1);
|
|
||||||
// const [notes, setNotes] = useState("");
|
|
||||||
// const [reasons, setReasons] = useState("");
|
|
||||||
|
|
||||||
// const [miiList, setMiiList] = useState<MiiList[]>([]);
|
|
||||||
// const [newMii, setNewMii] = useState<MiiList>({
|
|
||||||
// id: 0,
|
|
||||||
// reason: "",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const [error, setError] = useState<string | undefined>(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 (
|
|
||||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
|
|
||||||
// <div className="flex justify-center items-center gap-2">
|
|
||||||
// <input
|
|
||||||
// type="number"
|
|
||||||
// placeholder="Enter user ID to lookup..."
|
|
||||||
// name="user-id"
|
|
||||||
// value={userId !== -1 ? userId : ""}
|
|
||||||
// onChange={(e) => setUserId(Number(e.target.value))}
|
|
||||||
// className="pill input w-full max-w-lg"
|
|
||||||
// />
|
|
||||||
// <button onClick={handleLookup} className="pill button">
|
|
||||||
// Lookup User
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {user && (
|
|
||||||
// <div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
|
|
||||||
// <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
|
|
||||||
// <div className="flex gap-1">
|
|
||||||
// <ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
|
|
||||||
// <div className="p-2 flex flex-col">
|
|
||||||
// <p className="text-xl font-bold">{user.name}</p>
|
|
||||||
// <p className="text-black/60 text-sm font-medium">@{user.name}</p>
|
|
||||||
// <p className="text-sm mt-auto">
|
|
||||||
// <span className="font-medium">Created:</span>{" "}
|
|
||||||
// {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
|
|
||||||
// </p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <hr className="border-zinc-300 my-3" />
|
|
||||||
|
|
||||||
// <div className="flex flex-col gap-2">
|
|
||||||
// {user.punishments.length === 0 ? (
|
|
||||||
// <p className="text-center text-zinc-500 my-2">No punishments found.</p>
|
|
||||||
// ) : (
|
|
||||||
// <>
|
|
||||||
// {user.punishments.map((punishment) => (
|
|
||||||
// <div
|
|
||||||
// key={punishment.id}
|
|
||||||
// className={`border rounded-lg p-3 space-y-1 ${
|
|
||||||
// punishment.type === "WARNING"
|
|
||||||
// ? "bg-yellow-50 border-yellow-400"
|
|
||||||
// : punishment.type === "TEMP_EXILE"
|
|
||||||
// ? "bg-orange-100 border-orange-200"
|
|
||||||
// : "bg-red-50 border-red-200"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <div className="flex items-center justify-between mb-2">
|
|
||||||
// <span
|
|
||||||
// className={`border px-2 py-1 rounded text-xs font-semibold ${
|
|
||||||
// punishment.type === "WARNING"
|
|
||||||
// ? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
|
||||||
// : punishment.type === "TEMP_EXILE"
|
|
||||||
// ? "bg-orange-200 text-orange-800 border-orange-500"
|
|
||||||
// : "bg-red-200 text-red-800 border-red-500"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// {punishment.type}
|
|
||||||
// </span>
|
|
||||||
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// <span className="text-sm text-zinc-600">
|
|
||||||
// {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
|
|
||||||
// </span>
|
|
||||||
// <PunishmentDeletionDialog punishmentId={punishment.id} />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <p className="text-sm text-zinc-600">
|
|
||||||
// <strong>Notes:</strong> {punishment.notes}
|
|
||||||
// </p>
|
|
||||||
// {punishment.type !== "WARNING" && (
|
|
||||||
// <p className="text-sm text-zinc-600">
|
|
||||||
// <strong>Expires:</strong>{" "}
|
|
||||||
// {punishment.expiresAt
|
|
||||||
// ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
|
||||||
// : "Never"}
|
|
||||||
// </p>
|
|
||||||
// )}
|
|
||||||
// {punishment.type !== "PERM_EXILE" && (
|
|
||||||
// <p className="text-sm text-zinc-600">
|
|
||||||
// <strong>Returned:</strong> {JSON.stringify(punishment.returned)}
|
|
||||||
// </p>
|
|
||||||
// )}
|
|
||||||
// <p className="text-sm text-zinc-600">
|
|
||||||
// <strong>Reasons:</strong>
|
|
||||||
// </p>
|
|
||||||
// <ul className="ml-8 list-disc text-sm text-zinc-600">
|
|
||||||
// {punishment.reasons.map((reason, index) => (
|
|
||||||
// <li key={index}>{reason}</li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// <p className="text-sm text-zinc-600">
|
|
||||||
// <strong>Mii Reasons:</strong>
|
|
||||||
// </p>
|
|
||||||
// <ul className="ml-8 list-disc text-sm text-zinc-600">
|
|
||||||
// {punishment.violatingMiis.map((mii) => (
|
|
||||||
// <li key={mii.miiId}>
|
|
||||||
// {mii.miiId}: {mii.reason}
|
|
||||||
// </li>
|
|
||||||
// ))}
|
|
||||||
// </ul>
|
|
||||||
// </div>
|
|
||||||
// ))}
|
|
||||||
// </>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
|
|
||||||
// {/* Punishment type */}
|
|
||||||
// <p className="text-sm">Punishment Type</p>
|
|
||||||
// <select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
|
|
||||||
// <option value="WARNING">Warning</option>
|
|
||||||
// <option value="TEMP_EXILE">Temporary Exile</option>
|
|
||||||
// <option value="PERM_EXILE">Permanent Exile</option>
|
|
||||||
// </select>
|
|
||||||
|
|
||||||
// {/* Punishment duration */}
|
|
||||||
// {type === "TEMP_EXILE" && (
|
|
||||||
// <>
|
|
||||||
// <p className="text-sm">Duration</p>
|
|
||||||
// <select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
|
|
||||||
// <option value="1">1 Day</option>
|
|
||||||
// <option value="7">7 Days</option>
|
|
||||||
// <option value="30">30 Days</option>
|
|
||||||
// </select>
|
|
||||||
// </>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {/* Punishment notes */}
|
|
||||||
// <p className="text-sm">Notes</p>
|
|
||||||
// <textarea
|
|
||||||
// rows={2}
|
|
||||||
// maxLength={256}
|
|
||||||
// placeholder="Type notes here for the punishment..."
|
|
||||||
// className="pill input rounded-xl! resize-none"
|
|
||||||
// value={notes}
|
|
||||||
// onChange={(e) => setNotes(e.target.value)}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Punishment profile-related reasons */}
|
|
||||||
// <p className="text-sm">Profile-related reasons (split by comma)</p>
|
|
||||||
// <textarea
|
|
||||||
// rows={2}
|
|
||||||
// maxLength={256}
|
|
||||||
// placeholder="Type profile-related reasons here for the punishment..."
|
|
||||||
// className="pill input rounded-xl! resize-none"
|
|
||||||
// value={reasons}
|
|
||||||
// onChange={(e) => setReasons(e.target.value)}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// {/* Punishment mii-related reasons */}
|
|
||||||
// <p className="text-sm">Mii-related reasons</p>
|
|
||||||
// <div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
|
|
||||||
// {/* Add Mii Form */}
|
|
||||||
// <div className="flex gap-2">
|
|
||||||
// <input
|
|
||||||
// type="number"
|
|
||||||
// placeholder="Mii ID"
|
|
||||||
// className="pill input w-24 text-sm"
|
|
||||||
// value={newMii.id}
|
|
||||||
// onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
|
|
||||||
// />
|
|
||||||
// <input
|
|
||||||
// type="text"
|
|
||||||
// placeholder="Reason for this Mii..."
|
|
||||||
// className="pill input flex-1 text-sm"
|
|
||||||
// value={newMii.reason}
|
|
||||||
// onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
|
|
||||||
// />
|
|
||||||
// <button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
|
|
||||||
// <Icon icon="ic:baseline-plus" className="size-4" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Mii List */}
|
|
||||||
// {miiList.length > 0 && (
|
|
||||||
// <div className="mt-2 space-y-1">
|
|
||||||
// <p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
|
|
||||||
// {miiList.map((mii, index) => (
|
|
||||||
// <div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
|
|
||||||
// <div className="flex-1">
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// <span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span>
|
|
||||||
// <span className="text-sm text-gray-500">{mii.reason}</span>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// aria-label="Remove Mii"
|
|
||||||
// onClick={() => removeMiiFromList(index)}
|
|
||||||
// className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
|
|
||||||
// >
|
|
||||||
// <Icon icon="iconamoon:trash" className="size-4" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="flex justify-between items-center mt-2">
|
|
||||||
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
|
||||||
|
|
||||||
// <SubmitButton onClick={handleSubmit} className="ml-auto" />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
|
import { useStore } from "@nanostores/react";
|
||||||
import AdminBanner from "./components/admin/banner";
|
import AdminBanner from "./components/admin/banner";
|
||||||
import Footer from "./components/footer";
|
import Footer from "./components/footer";
|
||||||
import Header from "./components/header";
|
import Header from "./components/header";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { session } from "./session";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
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
|
// Calculate header height
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const header = document.querySelector("header");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import ProfileLayout from "./pages/profile/layout.tsx";
|
||||||
import ProfileLikesPage from "./pages/profile/likes.tsx";
|
import ProfileLikesPage from "./pages/profile/likes.tsx";
|
||||||
import ReportMiiPage from "./pages/report/mii.tsx";
|
import ReportMiiPage from "./pages/report/mii.tsx";
|
||||||
import ReportUserPage from "./pages/report/user.tsx";
|
import ReportUserPage from "./pages/report/user.tsx";
|
||||||
import AdminPage from "./pages/admin.tsx";
|
|
||||||
import EditMiiPage from "./pages/edit.tsx";
|
import EditMiiPage from "./pages/edit.tsx";
|
||||||
|
import PunishedPage from "./pages/punished.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
@ -47,7 +47,7 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<Route path="/out" element={<LinkOutPage />} />
|
<Route path="/out" element={<LinkOutPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
|
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/punished" element={<PunishedPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -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 <div className="p-6 text-center">Loading...</div>;
|
|
||||||
if ($session === null || Number($session?.user?.id) != import.meta.env.VITE_ADMIN_USER_ID) return <Navigate to="/404" replace />;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BannerForm />
|
|
||||||
<MiiList parentPage="admin" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -105,10 +105,10 @@ export default function ProfileLayout() {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isOwnProfile && isAdmin && (
|
{isOwnProfile && isAdmin && (
|
||||||
<Link aria-label="Go to Admin" to="/admin">
|
<a aria-label="Go to Admin" href={`${import.meta.env.VITE_API_URL}/admin`}>
|
||||||
<Icon icon="mdi:shield-moon" />
|
<Icon icon="mdi:shield-moon" />
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</Link>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isOwnProfile && page !== "/profile/likes" && (
|
{isOwnProfile && page !== "/profile/likes" && (
|
||||||
<Link aria-label="Go to My Likes" to="/profile/likes">
|
<Link aria-label="Go to My Likes" to="/profile/likes">
|
||||||
|
|
|
||||||
78
frontend/src/pages/punished.tsx
Normal file
78
frontend/src/pages/punished.tsx
Normal file
|
|
@ -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<any | null | undefined>(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 <div className="p-6 text-center">Loading...</div>;
|
||||||
|
if ($session === null || !activePunishment) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
const expiresAt = dayjs(activePunishment.expiresAt);
|
||||||
|
const createdAt = dayjs(activePunishment.createdAt);
|
||||||
|
const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date();
|
||||||
|
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grow flex items-center justify-center">
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
|
||||||
|
<h2 className="text-4xl font-black mb-2">
|
||||||
|
{activePunishment.type === "PERM_EXILE"
|
||||||
|
? "Exiled permanently"
|
||||||
|
: activePunishment.type === "TEMP_EXILE"
|
||||||
|
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
|
||||||
|
: "Warning"}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
You have been exiled from the TomodachiShare island because you violated the{" "}
|
||||||
|
<Link to={"/terms-of-service"} className="text-blue-500">
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-3">
|
||||||
|
<span className="font-bold">Reviewed:</span> {createdAt.toDate().toLocaleDateString("en-GB")} at {createdAt.toDate().toLocaleString("en-GB")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">
|
||||||
|
<span className="font-bold">Reason:</span> {activePunishment.reason}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-zinc-300 mt-2 mb-4" />
|
||||||
|
|
||||||
|
{activePunishment.type !== "PERM_EXILE" ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
|
||||||
|
<ReturnToIsland hasExpired={hasExpired} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Your punishment is permanent, therefore you cannot return.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue