feat: reimplement punishments

This commit is contained in:
trafficlunar 2026-04-21 13:22:46 +01:00
parent 7bd84ea454
commit 77828653ba
34 changed files with 1229 additions and 1068 deletions

View file

@ -1,26 +0,0 @@
import { useState } from "react";
export default function BannerForm() {
const [message, setMessage] = useState("");
const API_URL = import.meta.env.VITE_API_URL;
const onClickClear = async () => {
await fetch(`${API_URL}/api/admin/banner`, { method: "DELETE", credentials: "include" }); // TODO
};
const onClickSet = async () => {
await fetch(`${API_URL}/api/admin/banner`, { method: "POST", body: message, credentials: "include" });
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="pill button" onClick={onClickClear}>
Clear
</button>
<button type="submit" className="pill button" onClick={onClickSet}>
Set
</button>
</div>
);
}

View file

@ -1,45 +0,0 @@
// import { settings } from "@/lib/settings";
// import { useState } from "react";
// export default function ControlCenter() {
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
// const onClickSet = async () => {
// await fetch("/api/admin/can-submit", { method: "POST", body: JSON.stringify(canSubmit) });
// await fetch("/api/admin/queue", { method: "POST", body: JSON.stringify(isQueueEnabled) });
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
// <div className="flex items-center gap-2">
// <input
// id="submit"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={canSubmit}
// onChange={(e) => setCanSubmit(e.target.checked)}
// />
// <label htmlFor="submit">Enable Submissions</label>
// </div>
// <div className="flex items-center gap-2">
// <input
// id="queue"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={isQueueEnabled}
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
// />
// <label htmlFor="queue">Enable Queue</label>
// </div>
// <div className="flex gap-2 self-end">
// <button type="submit" className="pill button" onClick={onClickSet}>
// Set
// </button>
// </div>
// </div>
// );
// }

View file

@ -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&lsquo;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,
// )}
// </>
// );
// }

View file

@ -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>
// );
// }

View file

@ -1,84 +0,0 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
export default function RegenerateImagesButton() {
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/regenerate-metadata-images", { method: "POST" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
close();
};
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)} className="pill button w-fit">
Regenerate all Mii metadata images
</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">Regenerate Images</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 and regenerate every metadata image.</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,
)}
</>
);
}

View file

@ -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>
// );
// }

View file

@ -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>
// );
// }

View file

@ -1,51 +1,50 @@
// import { useState } from "react";
// import { Icon } from "@iconify/react";
// import { redirect } from "next/navigation";
import { useState } from "react";
import { Icon } from "@iconify/react";
import { useNavigate } from "react-router";
// interface Props {
// hasExpired: boolean;
// }
interface Props {
hasExpired: boolean;
}
// export default function ReturnToIsland({ hasExpired }: Props) {
// const [isChecked, setIsChecked] = useState(false);
// const [error, setError] = useState<string | undefined>(undefined);
export default function ReturnToIsland({ hasExpired }: Props) {
const navigate = useNavigate();
const [isChecked, setIsChecked] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
// const handleClick = async () => {
// const response = await fetch("/api/return", { method: "POST" });
const handleClick = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/return`, { method: "POST", credentials: "include" });
// if (!response.ok) {
// const data = await response.json();
// setError(data.error);
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
navigate("/");
};
// return;
// }
return (
<>
<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 (
// <>
// <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>
// <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>
// </>
// );
// }
{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>
</>
);
}

View file

@ -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>
// );
// }

View file

@ -1,9 +1,18 @@
import { useStore } from "@nanostores/react";
import AdminBanner from "./components/admin/banner";
import Footer from "./components/footer";
import Header from "./components/header";
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router";
import { session } from "./session";
export default function Layout({ children }: { children: React.ReactNode }) {
const $session = useStore(session);
const navigate = useNavigate();
const location = useLocation();
const API_URL = import.meta.env.VITE_API_URL;
// Calculate header height
useEffect(() => {
const header = document.querySelector("header");
@ -25,6 +34,24 @@ export default function Layout({ children }: { children: React.ReactNode }) {
};
}, []);
// Check for punishment on every page navigation
useEffect(() => {
if (!$session) return;
if (["/punished", "/terms-of-service", "/privacy"].includes(location.pathname)) return;
fetch(`${API_URL}/api/is-punished`, { credentials: "include" })
.then((res) => {
if (!res.ok) return null;
return res.json();
})
.then((data) => {
if (data.isPunished) navigate("/punished", { replace: true });
})
.catch((err) => {
console.error("Failed to check punishment status:", err);
});
}, [$session, location.pathname]);
return (
<>
<Header />

View file

@ -21,8 +21,8 @@ import ProfileLayout from "./pages/profile/layout.tsx";
import ProfileLikesPage from "./pages/profile/likes.tsx";
import ReportMiiPage from "./pages/report/mii.tsx";
import ReportUserPage from "./pages/report/user.tsx";
import AdminPage from "./pages/admin.tsx";
import EditMiiPage from "./pages/edit.tsx";
import PunishedPage from "./pages/punished.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
@ -47,7 +47,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/out" element={<LinkOutPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/punished" element={<PunishedPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>

View file

@ -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" />
</>
);
}

View file

@ -105,10 +105,10 @@ export default function ProfileLayout() {
</Link>
)}
{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" />
<span>Admin</span>
</Link>
</a>
)}
{isOwnProfile && page !== "/profile/likes" && (
<Link aria-label="Go to My Likes" to="/profile/likes">

View 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>
);
}