This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+467
-377
@@ -4,421 +4,511 @@ import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { bulkBan, bulkGiveBadge, bulkGiveCurrency, bulkUnban } from "@/actions/bulk-users";
|
||||
import {
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkGiveCurrency,
|
||||
bulkUnban,
|
||||
} from "@/actions/bulk-users";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
ipRegister: string;
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
ipRegister: string;
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
}
|
||||
|
||||
const RANK_LABELS: Record<number, string> = {
|
||||
1: "User",
|
||||
2: "Bronze",
|
||||
3: "Silver",
|
||||
4: "Gold",
|
||||
5: "Moderator",
|
||||
6: "Admin",
|
||||
7: "Super Admin",
|
||||
1: "User",
|
||||
2: "Bronze",
|
||||
3: "Silver",
|
||||
4: "Gold",
|
||||
5: "Moderator",
|
||||
6: "Admin",
|
||||
7: "Super Admin",
|
||||
};
|
||||
|
||||
function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
|
||||
const cols: DataTableColumn<UserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "User",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link href={`/admin/users/show/${row.id}`} className="flex items-center gap-2.5 hover:opacity-80">
|
||||
<img
|
||||
src={getAvatarUrl(row.look, { size: "s", headOnly: true, headDirection: 2 })}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.username}</span>
|
||||
{row.motto && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">{row.motto}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "mail", label: "Email", sortable: true },
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
sortable: true,
|
||||
filterKey: "filter_rank",
|
||||
filterOptions: [
|
||||
{ label: "User (1)", value: "1" },
|
||||
{ label: "Bronze (2)", value: "2" },
|
||||
{ label: "Silver (3)", value: "3" },
|
||||
{ label: "Gold (4)", value: "4" },
|
||||
{ label: "Moderator (5)", value: "5" },
|
||||
{ label: "Admin (6)", value: "6" },
|
||||
{ label: "Super Admin (7+)", value: "7" },
|
||||
],
|
||||
render: (value) => {
|
||||
const r = Number(value);
|
||||
return (
|
||||
<Badge variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}>
|
||||
{RANK_LABELS[r] || `Rank ${r}`}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => <span className="tabular-nums">{Number(value).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "Status",
|
||||
filterKey: "filter_online",
|
||||
filterOptions: [
|
||||
{ label: "Online", value: "1" },
|
||||
{ label: "Offline", value: "0" },
|
||||
],
|
||||
render: (value) =>
|
||||
value === "1" ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||
<Wifi className="h-3.5 w-3.5" /> Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accountCreated",
|
||||
label: "Registered",
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const ts = Number(value);
|
||||
if (!ts) return "-";
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild title="View">
|
||||
<Link href={`/admin/users/show/${row.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||
<Link href={`/admin/users/edit/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
const cols: DataTableColumn<UserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "User",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link
|
||||
href={`/admin/users/show/${row.id}`}
|
||||
className="flex items-center gap-2.5 hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(row.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
headDirection: 2,
|
||||
})}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.username}</span>
|
||||
{row.motto && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
|
||||
{row.motto}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "mail", label: "Email", sortable: true },
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
sortable: true,
|
||||
filterKey: "filter_rank",
|
||||
filterOptions: [
|
||||
{ label: "User (1)", value: "1" },
|
||||
{ label: "Bronze (2)", value: "2" },
|
||||
{ label: "Silver (3)", value: "3" },
|
||||
{ label: "Gold (4)", value: "4" },
|
||||
{ label: "Moderator (5)", value: "5" },
|
||||
{ label: "Admin (6)", value: "6" },
|
||||
{ label: "Super Admin (7+)", value: "7" },
|
||||
],
|
||||
render: (value) => {
|
||||
const r = Number(value);
|
||||
return (
|
||||
<Badge
|
||||
variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}
|
||||
>
|
||||
{RANK_LABELS[r] || `Rank ${r}`}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "Status",
|
||||
filterKey: "filter_online",
|
||||
filterOptions: [
|
||||
{ label: "Online", value: "1" },
|
||||
{ label: "Offline", value: "0" },
|
||||
],
|
||||
render: (value) =>
|
||||
value === "1" ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||
<Wifi className="h-3.5 w-3.5" /> Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accountCreated",
|
||||
label: "Registered",
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const ts = Number(value);
|
||||
if (!ts) return "-";
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild title="View">
|
||||
<Link href={`/admin/users/show/${row.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||
<Link href={`/admin/users/edit/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
return cols;
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: PaginatedResult<UserRow>;
|
||||
canEdit: boolean;
|
||||
canBan: boolean;
|
||||
data: PaginatedResult<UserRow>;
|
||||
canEdit: boolean;
|
||||
canBan: boolean;
|
||||
}
|
||||
|
||||
type PendingDialog =
|
||||
| { kind: "ban"; ids: number[] }
|
||||
| { kind: "give-currency"; ids: number[] }
|
||||
| { kind: "give-badge"; ids: number[] }
|
||||
| null;
|
||||
| { kind: "ban"; ids: number[] }
|
||||
| { kind: "give-currency"; ids: number[] }
|
||||
| { kind: "give-badge"; ids: number[] }
|
||||
| null;
|
||||
|
||||
export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
const [pending, setPending] = useState<PendingDialog>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [banReason, setBanReason] = useState("Bulk ban");
|
||||
const [banDuration, setBanDuration] = useState("-1");
|
||||
const [currencyType, setCurrencyType] = useState<"credits" | "pixels" | "points">("credits");
|
||||
const [currencyAmount, setCurrencyAmount] = useState("100");
|
||||
const [badgeCode, setBadgeCode] = useState("");
|
||||
const [pending, setPending] = useState<PendingDialog>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [banReason, setBanReason] = useState("Bulk ban");
|
||||
const [banDuration, setBanDuration] = useState("-1");
|
||||
const [currencyType, setCurrencyType] = useState<
|
||||
"credits" | "pixels" | "points"
|
||||
>("credits");
|
||||
const [currencyAmount, setCurrencyAmount] = useState("100");
|
||||
const [badgeCode, setBadgeCode] = useState("");
|
||||
|
||||
function reportResult(label: string, ok: number, total: number, failures = 0) {
|
||||
if (ok > 0) toast.success(`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`);
|
||||
if (failures > 0 && ok === 0) toast.error(`${label} failed for ${failures} users`);
|
||||
}
|
||||
function reportResult(
|
||||
label: string,
|
||||
ok: number,
|
||||
total: number,
|
||||
failures = 0,
|
||||
) {
|
||||
if (ok > 0)
|
||||
toast.success(
|
||||
`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`,
|
||||
);
|
||||
if (failures > 0 && ok === 0)
|
||||
toast.error(`${label} failed for ${failures} users`);
|
||||
}
|
||||
|
||||
async function handleBulkAction(action: string, ids: (number | string)[]) {
|
||||
const numericIds = ids.map((i) => Number(i));
|
||||
async function handleBulkAction(action: string, ids: (number | string)[]) {
|
||||
const numericIds = ids.map((i) => Number(i));
|
||||
|
||||
if (action === "unban") {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkUnban({ userIds: numericIds });
|
||||
if (result.ok) {
|
||||
const data = result.data as { unbanned: number; total: number };
|
||||
reportResult("Unbanned", data.unbanned, data.total);
|
||||
} else toast.error(result.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "unban") {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkUnban({ userIds: numericIds });
|
||||
if (result.ok) {
|
||||
const data = result.data as { unbanned: number; total: number };
|
||||
reportResult("Unbanned", data.unbanned, data.total);
|
||||
} else toast.error(result.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "ban") {
|
||||
setPending({ kind: "ban", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
if (action === "ban") {
|
||||
setPending({ kind: "ban", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "give-currency") {
|
||||
setPending({ kind: "give-currency", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
if (action === "give-currency") {
|
||||
setPending({ kind: "give-currency", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "give-badge") {
|
||||
setPending({ kind: "give-badge", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action === "give-badge") {
|
||||
setPending({ kind: "give-badge", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (pending?.kind !== "ban") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkBan({
|
||||
userIds: pending.ids,
|
||||
reason: banReason,
|
||||
duration: Number(banDuration),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as { banned: number };
|
||||
reportResult("Banned", data.banned, pending.ids.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmBan() {
|
||||
if (pending?.kind !== "ban") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkBan({
|
||||
userIds: pending.ids,
|
||||
reason: banReason,
|
||||
duration: Number(banDuration),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as { banned: number };
|
||||
reportResult("Banned", data.banned, pending.ids.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmGiveCurrency() {
|
||||
if (pending?.kind !== "give-currency") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveCurrency({
|
||||
userIds: pending.ids,
|
||||
amount: Number(currencyAmount),
|
||||
type: currencyType,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given ${currencyAmount} ${currencyType}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmGiveCurrency() {
|
||||
if (pending?.kind !== "give-currency") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveCurrency({
|
||||
userIds: pending.ids,
|
||||
amount: Number(currencyAmount),
|
||||
type: currencyType,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given ${currencyAmount} ${currencyType}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmGiveBadge() {
|
||||
if (pending?.kind !== "give-badge") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveBadge({
|
||||
userIds: pending.ids,
|
||||
badgeCode: badgeCode.trim().toUpperCase(),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(`Given badge ${badgeCode}`, data.given, data.total, data.failedIds.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmGiveBadge() {
|
||||
if (pending?.kind !== "give-badge") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveBadge({
|
||||
userIds: pending.ids,
|
||||
badgeCode: badgeCode.trim().toUpperCase(),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given badge ${badgeCode}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const bulkActions: { label: string; value: string; variant?: "destructive" | "default" }[] = [];
|
||||
if (canBan) {
|
||||
bulkActions.push({ label: "Ban", value: "ban", variant: "destructive" });
|
||||
bulkActions.push({ label: "Unban", value: "unban" });
|
||||
}
|
||||
if (canEdit) {
|
||||
bulkActions.push({ label: "Give credits", value: "give-currency" });
|
||||
bulkActions.push({ label: "Give badge", value: "give-badge" });
|
||||
}
|
||||
const bulkActions: {
|
||||
label: string;
|
||||
value: string;
|
||||
variant?: "destructive" | "default";
|
||||
}[] = [];
|
||||
if (canBan) {
|
||||
bulkActions.push({ label: "Ban", value: "ban", variant: "destructive" });
|
||||
bulkActions.push({ label: "Unban", value: "unban" });
|
||||
}
|
||||
if (canEdit) {
|
||||
bulkActions.push({ label: "Give credits", value: "give-currency" });
|
||||
bulkActions.push({ label: "Give badge", value: "give-badge" });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={getColumns(canEdit)}
|
||||
searchPlaceholder="Search by username, email or IP..."
|
||||
presets={[
|
||||
{ label: "Online now", params: { filter_online: "1" } },
|
||||
{ label: "Staff (rank ≥ 5)", params: { filter_rank: "5" } },
|
||||
{ label: "Super admins", params: { filter_rank: "7" } },
|
||||
]}
|
||||
bulkActions={bulkActions.length > 0 ? bulkActions : undefined}
|
||||
onBulkAction={bulkActions.length > 0 ? handleBulkAction : undefined}
|
||||
exportUrl={canEdit ? "/api/admin/export?type=users" : undefined}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={getColumns(canEdit)}
|
||||
searchPlaceholder="Search by username, email or IP..."
|
||||
presets={[
|
||||
{ label: "Online now", params: { filter_online: "1" } },
|
||||
{ label: "Staff (rank ≥ 5)", params: { filter_rank: "5" } },
|
||||
{ label: "Super admins", params: { filter_rank: "7" } },
|
||||
]}
|
||||
bulkActions={bulkActions.length > 0 ? bulkActions : undefined}
|
||||
onBulkAction={bulkActions.length > 0 ? handleBulkAction : undefined}
|
||||
exportUrl={canEdit ? "/api/admin/export?type=users" : undefined}
|
||||
/>
|
||||
|
||||
<Dialog open={pending?.kind === "ban"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban {pending?.kind === "ban" ? pending.ids.length : 0} users</DialogTitle>
|
||||
<DialogDescription>Permanent ban: leave duration at -1.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="ban-reason">Reason</Label>
|
||||
<Input id="ban-reason" value={banReason} onChange={(e) => setBanReason(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ban-duration">Duration (seconds, -1 = permanent)</Label>
|
||||
<Input
|
||||
id="ban-duration"
|
||||
type="number"
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmBan} disabled={busy || !banReason}>
|
||||
Ban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={pending?.kind === "ban"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Permanent ban: leave duration at -1.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="ban-reason">Reason</Label>
|
||||
<Input
|
||||
id="ban-reason"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ban-duration">
|
||||
Duration (seconds, -1 = permanent)
|
||||
</Label>
|
||||
<Input
|
||||
id="ban-duration"
|
||||
type="number"
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmBan}
|
||||
disabled={busy || !banReason}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pending?.kind === "give-currency"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give currency to {pending?.kind === "give-currency" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select value={currencyType} onValueChange={(v) => setCurrencyType(v as typeof currencyType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credits">Credits</SelectItem>
|
||||
<SelectItem value="pixels">Pixels (Duckets)</SelectItem>
|
||||
<SelectItem value="points">Points (Diamonds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="give-amount">Amount</Label>
|
||||
<Input
|
||||
id="give-amount"
|
||||
type="number"
|
||||
value={currencyAmount}
|
||||
onChange={(e) => setCurrencyAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmGiveCurrency} disabled={busy || !currencyAmount}>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={pending?.kind === "give-currency"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give currency to{" "}
|
||||
{pending?.kind === "give-currency" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={currencyType}
|
||||
onValueChange={(v) => setCurrencyType(v as typeof currencyType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credits">Credits</SelectItem>
|
||||
<SelectItem value="pixels">Pixels (Duckets)</SelectItem>
|
||||
<SelectItem value="points">Points (Diamonds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="give-amount">Amount</Label>
|
||||
<Input
|
||||
id="give-amount"
|
||||
type="number"
|
||||
value={currencyAmount}
|
||||
onChange={(e) => setCurrencyAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmGiveCurrency}
|
||||
disabled={busy || !currencyAmount}
|
||||
>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pending?.kind === "give-badge"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give badge to {pending?.kind === "give-badge" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="badge-code">Badge code</Label>
|
||||
<Input
|
||||
id="badge-code"
|
||||
value={badgeCode}
|
||||
onChange={(e) => setBadgeCode(e.target.value)}
|
||||
placeholder="ACH_*, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmGiveBadge} disabled={busy || !badgeCode.trim()}>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
<Dialog
|
||||
open={pending?.kind === "give-badge"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give badge to{" "}
|
||||
{pending?.kind === "give-badge" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="badge-code">Badge code</Label>
|
||||
<Input
|
||||
id="badge-code"
|
||||
value={badgeCode}
|
||||
onChange={(e) => setBadgeCode(e.target.value)}
|
||||
placeholder="ACH_*, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmGiveBadge}
|
||||
disabled={busy || !badgeCode.trim()}
|
||||
>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user