feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Achievement = {
|
||||
name: string;
|
||||
category: string;
|
||||
|
||||
@@ -5,8 +5,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
const WARNING = new Set(["warning", "warn"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
executeCommand,
|
||||
forwardUser,
|
||||
giveBadge,
|
||||
giveCredits,
|
||||
giveDiamonds,
|
||||
giveDuckets,
|
||||
hotelAlert,
|
||||
sendGift,
|
||||
setMotto,
|
||||
setRank,
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "@/actions/commandocentrum";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
const RCON_ERROR = "RCON command failed. Is the emulator running?";
|
||||
|
||||
export function CommandocentrumControls() {
|
||||
const t = useTranslations("pages.admin.commandocentrum");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const [hotelMessage, setHotelMessage] = useState("");
|
||||
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
|
||||
const [alert, setAlert] = useState({ userId: "", message: "" });
|
||||
const [forward, setForward] = useState({ userId: "", roomId: "" });
|
||||
const [credits, setCredits] = useState({ userId: "", credits: "" });
|
||||
const [duckets, setDuckets] = useState({ userId: "", amount: "" });
|
||||
const [diamonds, setDiamonds] = useState({ userId: "", amount: "" });
|
||||
const [badge, setBadge] = useState({ userId: "", badge: "" });
|
||||
const [motto, setMottoForm] = useState({ userId: "", motto: "" });
|
||||
const [rank, setRankForm] = useState({ userId: "", rank: "" });
|
||||
const [command, setCommand] = useState({ userId: "", command: "" });
|
||||
const [gift, setGift] = useState({
|
||||
userId: "",
|
||||
itemId: "",
|
||||
message: t("defaultGiftMessage"),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateCatalog")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateCatalogDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateCatalog(), {
|
||||
successMessage: "Catalog updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateCatalog")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateWordFilter")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateWordFilterDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateWordFilter(), {
|
||||
successMessage: "Word filter updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateWordFilter")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateNavigator")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateNavigatorDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateNavigator(), {
|
||||
successMessage: "Navigator updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("updateNavigator")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card mt-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={hotelMessage}
|
||||
onChange={(e) => setHotelMessage(e.target.value)}
|
||||
required
|
||||
maxLength={512}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-[220px]"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isPending || !hotelMessage.trim()}
|
||||
onClick={() =>
|
||||
run(() => hotelAlert({ message: hotelMessage }), {
|
||||
successMessage: "Hotel alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setHotelMessage(""),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sendAlert")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("userActions")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("userActionsDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.disconnect")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("disconnectDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={disconnect.userId}
|
||||
onChange={(e) =>
|
||||
setDisconnect((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("usernameRequired")}
|
||||
value={disconnect.username}
|
||||
onChange={(e) =>
|
||||
setDisconnect((s) => ({ ...s, username: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger w-full"
|
||||
disabled={
|
||||
isPending ||
|
||||
!disconnect.userId ||
|
||||
!disconnect.username.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
disconnectUser({
|
||||
userId: Number(disconnect.userId),
|
||||
username: disconnect.username,
|
||||
}),
|
||||
{
|
||||
successMessage: "User disconnected.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDisconnect({ userId: "", username: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.alert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("alertDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={alert.userId}
|
||||
onChange={(e) =>
|
||||
setAlert((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("alertMessage")}
|
||||
value={alert.message}
|
||||
onChange={(e) =>
|
||||
setAlert((s) => ({ ...s, message: e.target.value }))
|
||||
}
|
||||
maxLength={512}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={
|
||||
isPending || !alert.userId || !alert.message.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
alertUser({
|
||||
userId: Number(alert.userId),
|
||||
message: alert.message,
|
||||
}),
|
||||
{
|
||||
successMessage: "Alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setAlert({ userId: "", message: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.alert")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("forwardToRoom")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("forwardDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={forward.userId}
|
||||
onChange={(e) =>
|
||||
setForward((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("roomId")}
|
||||
value={forward.roomId}
|
||||
onChange={(e) =>
|
||||
setForward((s) => ({ ...s, roomId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !forward.userId || !forward.roomId}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
forwardUser({
|
||||
userId: Number(forward.userId),
|
||||
roomId: Number(forward.roomId),
|
||||
}),
|
||||
{
|
||||
successMessage: "User forwarded.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setForward({ userId: "", roomId: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("forwardUser")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveCredits")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveCreditsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={credits.userId}
|
||||
onChange={(e) =>
|
||||
setCredits((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={credits.credits}
|
||||
onChange={(e) =>
|
||||
setCredits((s) => ({ ...s, credits: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !credits.userId || !credits.credits}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveCredits({
|
||||
userId: Number(credits.userId),
|
||||
credits: Number(credits.credits),
|
||||
}),
|
||||
{
|
||||
successMessage: "Credits sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCredits({ userId: "", credits: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveCredits")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDuckets")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDucketsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={duckets.userId}
|
||||
onChange={(e) =>
|
||||
setDuckets((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={duckets.amount}
|
||||
onChange={(e) =>
|
||||
setDuckets((s) => ({ ...s, amount: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !duckets.userId || !duckets.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveDuckets({
|
||||
userId: Number(duckets.userId),
|
||||
amount: Number(duckets.amount),
|
||||
}),
|
||||
{
|
||||
successMessage: "Duckets sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDuckets({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveDuckets")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDiamonds")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDiamondsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={diamonds.userId}
|
||||
onChange={(e) =>
|
||||
setDiamonds((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
value={diamonds.amount}
|
||||
onChange={(e) =>
|
||||
setDiamonds((s) => ({ ...s, amount: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !diamonds.userId || !diamonds.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveDiamonds({
|
||||
userId: Number(diamonds.userId),
|
||||
amount: Number(diamonds.amount),
|
||||
}),
|
||||
{
|
||||
successMessage: "Diamonds sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDiamonds({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveDiamonds")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveBadge")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveBadgeDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={badge.userId}
|
||||
onChange={(e) =>
|
||||
setBadge((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("badgeCode")}
|
||||
value={badge.badge}
|
||||
onChange={(e) =>
|
||||
setBadge((s) => ({ ...s, badge: e.target.value }))
|
||||
}
|
||||
maxLength={32}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !badge.userId || !badge.badge.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
giveBadge({
|
||||
userId: Number(badge.userId),
|
||||
badge: badge.badge,
|
||||
}),
|
||||
{
|
||||
successMessage: "Badge granted.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setBadge({ userId: "", badge: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("giveBadge")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setMotto")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setMottoDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={motto.userId}
|
||||
onChange={(e) =>
|
||||
setMottoForm((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newMotto")}
|
||||
value={motto.motto}
|
||||
onChange={(e) =>
|
||||
setMottoForm((s) => ({ ...s, motto: e.target.value }))
|
||||
}
|
||||
maxLength={127}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !motto.userId || !motto.motto.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
setMotto({
|
||||
userId: Number(motto.userId),
|
||||
motto: motto.motto,
|
||||
}),
|
||||
{
|
||||
successMessage: "Motto updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setMottoForm({ userId: "", motto: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("setMotto")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setRank")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setRankDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={rank.userId}
|
||||
onChange={(e) =>
|
||||
setRankForm((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder={t("rankPlaceholder")}
|
||||
value={rank.rank}
|
||||
onChange={(e) =>
|
||||
setRankForm((s) => ({ ...s, rank: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !rank.userId || rank.rank === ""}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
setRank({
|
||||
userId: Number(rank.userId),
|
||||
rank: Number(rank.rank),
|
||||
}),
|
||||
{
|
||||
successMessage: "Rank updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setRankForm({ userId: "", rank: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("setRank")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("executeCommand")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("executeCommandDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={command.userId}
|
||||
onChange={(e) =>
|
||||
setCommand((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("commandPlaceholder")}
|
||||
value={command.command}
|
||||
onChange={(e) =>
|
||||
setCommand((s) => ({ ...s, command: e.target.value }))
|
||||
}
|
||||
maxLength={100}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={
|
||||
isPending || !command.userId || !command.command.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
executeCommand({
|
||||
userId: Number(command.userId),
|
||||
command: command.command,
|
||||
}),
|
||||
{
|
||||
successMessage: "Command executed.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCommand({ userId: "", command: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("execute")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("sendGift")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("sendGiftDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
value={gift.userId}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, userId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("furniId")}
|
||||
value={gift.itemId}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, itemId: e.target.value }))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("giftMessage")}
|
||||
value={gift.message}
|
||||
onChange={(e) =>
|
||||
setGift((s) => ({ ...s, message: e.target.value }))
|
||||
}
|
||||
maxLength={255}
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isPending || !gift.userId || !gift.itemId}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
sendGift({
|
||||
userId: Number(gift.userId),
|
||||
itemId: Number(gift.itemId),
|
||||
message: gift.message,
|
||||
}),
|
||||
{
|
||||
successMessage: "Gift sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setGift({
|
||||
userId: "",
|
||||
itemId: "",
|
||||
message: t("defaultGiftMessage"),
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("sendGift")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,6 @@
|
||||
import os from "node:os";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
executeCommand,
|
||||
forwardUser,
|
||||
giveBadge,
|
||||
giveCredits,
|
||||
giveDiamonds,
|
||||
giveDuckets,
|
||||
hotelAlert,
|
||||
sendGift,
|
||||
setMotto,
|
||||
setRank,
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "@/actions/commandocentrum";
|
||||
import {
|
||||
DiagnosticRow,
|
||||
InfoItem,
|
||||
@@ -28,6 +11,7 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -223,421 +207,7 @@ export default async function CommandoCentrum() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<form action={updateCatalog} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateCatalog")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateCatalogDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateCatalog")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={updateWordFilter} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateWordFilter")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateWordFilterDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateWordFilter")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={updateNavigator} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateNavigator")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateNavigatorDesc")}
|
||||
</p>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("updateNavigator")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action={hotelAlert} className="admin-card mt-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="message"
|
||||
required
|
||||
maxLength={512}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-[220px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("sendAlert")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── User RCON actions ──────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("userActions")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("userActionsDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<form action={disconnectUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.disconnect")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("disconnectDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernameRequired")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger w-full">
|
||||
{t("actions.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={alertUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.alert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("alertDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="message"
|
||||
type="text"
|
||||
placeholder={t("alertMessage")}
|
||||
required
|
||||
maxLength={512}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("actions.alert")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={forwardUser} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("forwardToRoom")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("forwardDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="roomId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("roomId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("forwardUser")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={giveCredits} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveCredits")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveCreditsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="credits"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveCredits")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={giveDuckets} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDuckets")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDucketsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveDuckets")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={giveDiamonds} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDiamonds")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveDiamondsDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("amount")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveDiamonds")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={giveBadge} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveBadge")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("giveBadgeDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="badge"
|
||||
type="text"
|
||||
placeholder={t("badgeCode")}
|
||||
required
|
||||
maxLength={32}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("giveBadge")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={setMotto} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setMotto")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setMottoDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="motto"
|
||||
type="text"
|
||||
placeholder={t("newMotto")}
|
||||
required
|
||||
maxLength={127}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("setMotto")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={setRank} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setRank")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("setRankDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="rank"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder={t("rankPlaceholder")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("setRank")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<form action={executeCommand} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("executeCommand")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("executeCommandDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="command"
|
||||
type="text"
|
||||
placeholder={t("commandPlaceholder")}
|
||||
required
|
||||
maxLength={100}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("execute")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={sendGift} className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("sendGift")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("sendGiftDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="itemId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("furniId")}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
name="message"
|
||||
type="text"
|
||||
placeholder={t("giftMessage")}
|
||||
maxLength={255}
|
||||
defaultValue={t("defaultGiftMessage")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("sendGift")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<CommandocentrumControls />
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
|
||||
@@ -8,8 +8,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminEmailTemplates() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteEvent } from "@/actions/events";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface EventRow {
|
||||
@@ -35,6 +38,8 @@ interface EventsTableProps {
|
||||
|
||||
export function EventsTable({ data }: EventsTableProps) {
|
||||
const t = useTranslations("pages.admin.events");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
published: t("statusPublished"),
|
||||
@@ -43,6 +48,20 @@ export function EventsTable({ data }: EventsTableProps) {
|
||||
completed: t("statusCompleted"),
|
||||
};
|
||||
|
||||
async function handleDelete(row: EventRow) {
|
||||
const ok = await confirm({
|
||||
title: "Delete event",
|
||||
description: `Delete "${row.title}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteEvent({ id: row.id }), {
|
||||
successMessage: "Event deleted!",
|
||||
errorMessage: "Failed to delete event.",
|
||||
});
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<EventRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{
|
||||
@@ -88,20 +107,34 @@ export function EventsTable({ data }: EventsTableProps) {
|
||||
key: "actions",
|
||||
label: t("colActions"),
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/events/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/events/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(row)}
|
||||
aria-label="Delete event"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { FaviconForm } from "./favicon-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminFaviconPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,52 +56,6 @@ interface PageMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
|
||||
|
||||
async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (classname: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (
|
||||
evt.type === "item_progress" &&
|
||||
(evt.status === "done" || evt.status === "failed")
|
||||
) {
|
||||
onDone(evt.classname);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
|
||||
// ── Source form ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface SourceFormProps {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,52 +44,6 @@ type Mode = "libs" | "sets";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
// ── Shared SSE reader ─────────────────────────────────────────────────────────
|
||||
|
||||
async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (label: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (
|
||||
evt.type === "item_progress" &&
|
||||
(evt.status === "done" || evt.status === "failed")
|
||||
) {
|
||||
onDone(evt.classname);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
|
||||
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
|
||||
|
||||
// A default dressed avatar; the previewed part overrides/adds its slot so the
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
|
||||
interface PetItem {
|
||||
lib: string;
|
||||
@@ -109,34 +110,22 @@ export function ImportPetsClient() {
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let done = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(part.slice(6));
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
markDone(evt.classname);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded;
|
||||
failed = evt.failed;
|
||||
}
|
||||
await readSseStream(res.body, (evt) => {
|
||||
if (evt.type === "item_progress" && evt.status === "done") {
|
||||
done++;
|
||||
markDone(String(evt.classname ?? ""));
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "item_progress" && evt.status === "failed") {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
} else if (evt.type === "batch_complete") {
|
||||
succeeded = Number(evt.succeeded ?? 0);
|
||||
failed = Number(evt.failed ?? 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
setBatchProgress(null);
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
|
||||
|
||||
@@ -14,6 +14,8 @@ import { setCsrfCookie } from "@/lib/foundation/security";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be
|
||||
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({
|
||||
|
||||
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
|
||||
// navigator. The Prisma delegate exists because the table has a primary key.
|
||||
type Flatcat = {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deletePoll } from "@/actions/polls";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface PollRow {
|
||||
@@ -31,6 +34,8 @@ interface PollsTableProps {
|
||||
|
||||
export function PollsTable({ data }: PollsTableProps) {
|
||||
const t = useTranslations("pages.admin.polls");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
active: t("statusActive"),
|
||||
@@ -38,6 +43,20 @@ export function PollsTable({ data }: PollsTableProps) {
|
||||
closed: t("statusClosed"),
|
||||
};
|
||||
|
||||
async function handleDelete(row: PollRow) {
|
||||
const ok = await confirm({
|
||||
title: "Delete poll",
|
||||
description: `Delete "${row.title}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deletePoll({ id: row.id }), {
|
||||
successMessage: "Poll deleted!",
|
||||
errorMessage: "Failed to delete poll.",
|
||||
});
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<PollRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{
|
||||
@@ -82,20 +101,34 @@ export function PollsTable({ data }: PollsTableProps) {
|
||||
key: "actions",
|
||||
label: t("colActions"),
|
||||
render: (_, row) => (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/polls/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/polls/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(row)}
|
||||
aria-label="Delete poll"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -241,6 +241,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "require_email_verification",
|
||||
label: "Require email verification",
|
||||
description:
|
||||
"Block login until the account email is verified (accounts without email are unaffected).",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "max_accounts_per_ip",
|
||||
label: "Max accounts per IP",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SubRow = {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
|
||||
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Tag = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
|
||||
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { VouchersClient, type VoucherRow } from "./vouchers-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminVouchers() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { WordFilterClient } from "./wordfilter-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminWordFilter() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
|
||||
|
||||
Reference in new issue
Block a user