feat(admin): ban appeals, photos polish, economy adjust, staff smoke
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
fe46bd0544
commit
3ad3f9512c
36 files changed
+1185
-293
No files matched your search
@@ -107,6 +107,17 @@ export default async function HelpTicketsPage({
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" name="banAppeal" value="1" />
|
||||
{t("banAppealLabel")}
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("submit")}
|
||||
|
||||
+106
-102
@@ -1,6 +1,8 @@
|
||||
import { Ban } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||
@@ -33,112 +35,114 @@ export default async function AdminBans() {
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ban")}
|
||||
</Button>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(new Date(b.banExpire * 1000))}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<Button type="submit" variant="outline">
|
||||
{t("lift")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ban")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(new Date(b.banExpire * 1000))}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<Button type="submit" variant="outline">
|
||||
{t("lift")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
closeHelpCenterTicket,
|
||||
liftBanFromHelpTicket,
|
||||
reopenHelpCenterTicket,
|
||||
replyHelpCenterTicket,
|
||||
} from "@/actions/admin-help-tickets";
|
||||
@@ -30,6 +31,7 @@ interface HelpTicketInfo {
|
||||
open: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName?: string | null;
|
||||
creator: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -42,12 +44,17 @@ export function AdminHelpTicketDetail({
|
||||
ticket,
|
||||
messages: initialMessages,
|
||||
canEdit,
|
||||
canLiftBan = false,
|
||||
hasActiveBan = false,
|
||||
listHref = "/admin/help-tickets",
|
||||
hideRequesterContact = false,
|
||||
}: {
|
||||
ticket: HelpTicketInfo;
|
||||
messages: ThreadMessage[];
|
||||
canEdit: boolean;
|
||||
/** Staff may lift requester bans (USERS_BAN). */
|
||||
canLiftBan?: boolean;
|
||||
hasActiveBan?: boolean;
|
||||
listHref?: string;
|
||||
/** Hide email/IP-style contact for mod-lite surfaces. */
|
||||
hideRequesterContact?: boolean;
|
||||
@@ -94,6 +101,13 @@ export function AdminHelpTicketDetail({
|
||||
});
|
||||
}
|
||||
|
||||
function handleLiftBan() {
|
||||
if (!ticket.creator || isPending) return;
|
||||
run(() => liftBanFromHelpTicket({ ticketId: ticket.id }), {
|
||||
successMessage: t("success.banLifted"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -110,6 +124,12 @@ export function AdminHelpTicketDetail({
|
||||
<Badge variant={ticket.open ? "default" : "secondary"}>
|
||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||
</Badge>
|
||||
{ticket.categoryName ? (
|
||||
<Badge variant="outline">{ticket.categoryName}</Badge>
|
||||
) : null}
|
||||
{hasActiveBan ? (
|
||||
<Badge variant="destructive">{t("requesterBanned")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
||||
</div>
|
||||
@@ -208,13 +228,24 @@ export function AdminHelpTicketDetail({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{canEdit && (
|
||||
{(canEdit || (canLiftBan && hasActiveBan)) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ticket.open ? (
|
||||
{canLiftBan && hasActiveBan && ticket.creator ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
onClick={handleLiftBan}
|
||||
>
|
||||
{t("liftBanSubmit")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit && ticket.open ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -224,7 +255,8 @@ export function AdminHelpTicketDetail({
|
||||
>
|
||||
{t("closeSubmit")}
|
||||
</Button>
|
||||
) : (
|
||||
) : null}
|
||||
{canEdit && !ticket.open ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -234,7 +266,7 @@ export function AdminHelpTicketDetail({
|
||||
>
|
||||
{t("reopenSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { activeBanWhere } from "@/lib/bans";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
||||
@@ -29,6 +30,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
categoryId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
open: true,
|
||||
@@ -57,9 +59,9 @@ export default async function AdminHelpTicketDetailPage({
|
||||
]),
|
||||
];
|
||||
|
||||
const users =
|
||||
const [users, category, activeBan] = await Promise.all([
|
||||
authorIds.length > 0
|
||||
? await prisma.user.findMany({
|
||||
? prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -68,7 +70,20 @@ export default async function AdminHelpTicketDetailPage({
|
||||
mail: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
: Promise.resolve([]),
|
||||
ticket.categoryId != null
|
||||
? prisma.websiteHelpCenterCategories.findUnique({
|
||||
where: { id: ticket.categoryId },
|
||||
select: { name: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ticket.userId != null
|
||||
? prisma.ban.findFirst({
|
||||
where: { userId: ticket.userId, ...activeBanWhere() },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const openerId = ticket.userId;
|
||||
@@ -104,6 +119,10 @@ export default async function AdminHelpTicketDetailPage({
|
||||
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
const canLiftBan = canAccess(permissions, PERMS.USERS_BAN, session.user.rank);
|
||||
const isBanAppeal =
|
||||
/ban\s*appeal/i.test(ticket.title) ||
|
||||
/ban\s*appeal/i.test(category?.name ?? "");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -119,6 +138,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
open: ticket.open,
|
||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||
categoryName: category?.name ?? (isBanAppeal ? "Ban appeal" : null),
|
||||
creator: creator
|
||||
? {
|
||||
id: creator.id,
|
||||
@@ -130,6 +150,8 @@ export default async function AdminHelpTicketDetailPage({
|
||||
}}
|
||||
messages={messages}
|
||||
canEdit={canEdit}
|
||||
canLiftBan={canLiftBan}
|
||||
hasActiveBan={!!activeBan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+106
-52
@@ -1,35 +1,62 @@
|
||||
import { Camera } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deletePhoto } from "@/actions/admin-photos";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPhotos() {
|
||||
export default async function AdminPhotos({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.photos");
|
||||
const parsed = parseListParams(new URLSearchParams(await searchParams));
|
||||
|
||||
const total = await prisma.cameraWeb.count().catch(() => 0);
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage || 24);
|
||||
|
||||
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
|
||||
try {
|
||||
photos = await prisma.cameraWeb.findMany({
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 60,
|
||||
skip: pagination.offset,
|
||||
take: pagination.perPage,
|
||||
});
|
||||
} catch {
|
||||
photos = [];
|
||||
}
|
||||
|
||||
const userIds = [...new Set(photos.map((p) => p.userId))];
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
|
||||
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
|
||||
const canEdit = canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<AdminPageShell icon={Camera} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
|
||||
<StatusCard
|
||||
@@ -44,68 +71,95 @@ export default async function AdminPhotos() {
|
||||
hint={t("distinctPhotographers")}
|
||||
icon="👤"
|
||||
/>
|
||||
<StatusCard label={t("total")} value={total} icon="🗂" />
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<section className="mt-2">
|
||||
<h2 className="admin-section-title">{t("recentPhotos")}</h2>
|
||||
{photos.length === 0 ? (
|
||||
<div className="admin-empty">{t("noPhotos")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{photos.map((p) => (
|
||||
<div
|
||||
key={String(p.id)}
|
||||
className="admin-card"
|
||||
style={{ padding: 0, overflow: "hidden" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={t("photoAlt", { id: String(p.id) })}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
background: "#f1f5f9",
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
|
||||
<div
|
||||
{photos.map((p) => {
|
||||
const username = usernameById.get(p.userId) ?? `#${p.userId}`;
|
||||
return (
|
||||
<div
|
||||
key={String(p.id)}
|
||||
className="admin-card"
|
||||
style={{ padding: 0, overflow: "hidden" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={t("photoAlt", { id: String(p.id) })}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
alignItems: "baseline",
|
||||
width: "100%",
|
||||
height: 200,
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
background: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
<strong>#{String(p.id)}</strong>
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("colRoom")} {p.roomId}
|
||||
</span>
|
||||
/>
|
||||
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<strong>#{String(p.id)}</strong>
|
||||
<Link
|
||||
href={`/admin/rooms/edit/${p.roomId}`}
|
||||
className="text-sm theme-text-muted dark:theme-text-muted hover:underline"
|
||||
>
|
||||
{t("colRoom")} {p.roomId}
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs theme-text-muted dark:theme-text-muted"
|
||||
style={{ margin: "0.3rem 0 0" }}
|
||||
>
|
||||
<Link
|
||||
href={`/admin/users/show/${p.userId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{t("byUser", { username })}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
{new Date(p.timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace("T", " ")}
|
||||
</p>
|
||||
{canEdit ? (
|
||||
<form
|
||||
action={deletePhoto}
|
||||
style={{ marginTop: "0.75rem" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs theme-text-muted dark:theme-text-muted"
|
||||
style={{ margin: "0.3rem 0 0" }}
|
||||
>
|
||||
{t("colUser")} {p.userId} ·{" "}
|
||||
{new Date(p.timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace("T", " ")}
|
||||
</p>
|
||||
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pagination.lastPage > 1 ? (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
{t("pageOf", {
|
||||
page: pagination.page,
|
||||
last: pagination.lastPage,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { setTradeLock } from "@/actions/bulk-users";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
@@ -234,6 +235,40 @@ export function UserQuickActions({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade lock (DB only — no RCON helper) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Trade lock (CMS DB)
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
const until = Math.floor(Date.now() / 1000) + 7 * 24 * 3600;
|
||||
run(() => setTradeLock({ userId, untilUnix: until }), {
|
||||
successMessage: `${username} trade-locked for 7 days.`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Lock 7d
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
run(() => setTradeLock({ userId, untilUnix: 0 }), {
|
||||
successMessage: `${username} trade lock cleared.`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear lock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { CreateUserDialog } from "./_components/create-user-dialog";
|
||||
@@ -43,17 +46,22 @@ export default async function UsersPage({
|
||||
searchParams,
|
||||
);
|
||||
|
||||
const t = await getTranslations("pages.admin.users");
|
||||
const canEdit = canAccess(result.permissions, PERMS.USERS_EDIT, result.rank);
|
||||
const canBan = canAccess(result.permissions, PERMS.USERS_BAN, result.rank);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{result.total.toLocaleString()} users total
|
||||
</p>
|
||||
{canEdit && <CreateUserDialog sessionRank={result.rank} />}
|
||||
</div>
|
||||
<AdminPageShell
|
||||
icon={Users}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
actions={
|
||||
canEdit ? <CreateUserDialog sessionRank={result.rank} /> : undefined
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground m-0">
|
||||
{result.total.toLocaleString()} users total
|
||||
</p>
|
||||
|
||||
<UsersTable
|
||||
data={{
|
||||
@@ -66,6 +74,6 @@ export default async function UsersPage({
|
||||
canEdit={canEdit}
|
||||
canBan={canBan}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getWatchedUserIds } from "@/lib/services/watch";
|
||||
@@ -23,6 +24,7 @@ export default async function UserShowPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.users.detail");
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const sanctions = await loadUserSanctions(data.user.id);
|
||||
@@ -53,15 +55,17 @@ export default async function UserShowPage({
|
||||
>
|
||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
Badges ({data.user.badges.length})
|
||||
{t("tabBadges", { count: data.user.badges.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">
|
||||
{t("tabRooms", { count: data.roomCount })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="sanctions">
|
||||
Sanctions ({sanctions.events.length})
|
||||
{t("tabSanctions", { count: sanctions.events.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
|
||||
@@ -5,9 +5,9 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
bulkAdjustCurrency,
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkGiveCurrency,
|
||||
bulkUnban,
|
||||
} from "@/actions/bulk-users";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
@@ -283,20 +283,20 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
if (pending?.kind !== "give-currency") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveCurrency({
|
||||
const result = await bulkAdjustCurrency({
|
||||
userIds: pending.ids,
|
||||
amount: Number(currencyAmount),
|
||||
type: currencyType,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
adjusted: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given ${currencyAmount} ${currencyType}`,
|
||||
data.given,
|
||||
`Adjusted ${currencyAmount} ${currencyType}`,
|
||||
data.adjusted,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
@@ -344,7 +344,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
bulkActions.push({ label: "Unban", value: "unban" });
|
||||
}
|
||||
if (canEdit) {
|
||||
bulkActions.push({ label: "Give credits", value: "give-currency" });
|
||||
bulkActions.push({ label: "Adjust currency", value: "give-currency" });
|
||||
bulkActions.push({ label: "Give badge", value: "give-badge" });
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give currency to{" "}
|
||||
Adjust currency for{" "}
|
||||
{pending?.kind === "give-currency" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -459,7 +459,9 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="give-amount">Amount</Label>
|
||||
<Label htmlFor="give-amount">
|
||||
Amount (positive = give, negative = take)
|
||||
</Label>
|
||||
<Input
|
||||
id="give-amount"
|
||||
type="number"
|
||||
@@ -478,9 +480,9 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmGiveCurrency}
|
||||
disabled={busy || !currencyAmount}
|
||||
disabled={busy || !currencyAmount || Number(currencyAmount) === 0}
|
||||
>
|
||||
Give
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in new issue
Block a user