refactor(admin): organize user actions and improve content editing UX
CI / check (push) Successful in 1m32s
CI / deploy (push) Successful in 1m11s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 18:56:56 +02:00
1 parent e17346ab9f
commit c6b919c01d
10 files changed
+688 -458

No files matched your search

@@ -1,3 +1,4 @@
"use client";
import {
ArrowLeftRight,
DoorOpen,
@@ -8,6 +9,7 @@ import {
Wifi,
WifiOff,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
import Link from "@/components/link";
@@ -15,6 +17,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getAvatarUrl } from "@/lib/imager";
import { userDetailHref, userListHref } from "../user-list-navigation";
interface UserHeroProps {
user: {
@@ -55,11 +58,14 @@ export function UserHero({
canEdit,
watching,
}: UserHeroProps) {
const params = useSearchParams();
const list = params.get("list") ?? "";
const listHref = userListHref(list);
return (
<>
<AdminBreadcrumb
items={[
{ label: "Users", href: "/admin/users" },
{ label: "Users", href: listHref },
{ label: user.username },
{ label: mode === "edit" ? "Edit" : "View" },
]}
@@ -99,14 +105,14 @@ export function UserHero({
{/* Mode switch buttons */}
{mode === "show" && canEdit && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={`/admin/users/edit/${user.id}`}>
<Link href={userDetailHref(user.id, "edit", list)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
</Link>
</Button>
)}
{mode === "edit" && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={`/admin/users/show/${user.id}`}>
<Link href={userDetailHref(user.id, "show", list)}>
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
</Link>
</Button>
@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import {
bulkAdjustCurrency,
bulkBan,
bulkGiveBadge,
bulkUnban,
} from "@/actions/bulk-users";
type PendingDialog =
| { kind: "ban"; ids: number[] }
| { kind: "give-currency"; ids: number[] }
| { kind: "give-badge"; ids: number[] }
| null;
export function useUserBulkActions() {
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`);
}
async function handleBulkAction(action: string, ids: (number | string)[]) {
if (busy) return;
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);
} catch {
toast.error(
"Network error. The outcome is uncertain; check the user before retrying.",
);
} finally {
setBusy(false);
}
return;
}
if (action === "ban") {
setPending({ kind: "ban", 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;
}
}
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);
setPending(null);
} else toast.error(result.error);
} catch {
toast.error(
"Network error. The outcome is uncertain; check the user before retrying.",
);
} finally {
setBusy(false);
}
}
async function confirmGiveCurrency() {
if (pending?.kind !== "give-currency") return;
setBusy(true);
try {
const result = await bulkAdjustCurrency({
userIds: pending.ids,
amount: Number(currencyAmount),
type: currencyType,
});
if (result.ok) {
const data = result.data as {
adjusted: number;
total: number;
failedIds: { userId: number; reason: string }[];
};
reportResult(
`Adjusted ${currencyAmount} ${currencyType}`,
data.adjusted,
data.total,
data.failedIds.length,
);
setPending(null);
} else toast.error(result.error);
} catch {
toast.error(
"Network error. The outcome is uncertain; check the user before retrying.",
);
} 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,
);
setPending(null);
} else toast.error(result.error);
} catch {
toast.error(
"Network error. The outcome is uncertain; check the user before retrying.",
);
} finally {
setBusy(false);
}
}
return {
pending,
setPending,
busy,
banReason,
setBanReason,
banDuration,
setBanDuration,
currencyType,
setCurrencyType,
currencyAmount,
setCurrencyAmount,
badgeCode,
setBadgeCode,
handleBulkAction,
confirmBan,
confirmGiveCurrency,
confirmGiveBadge,
};
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getAvatarUrl } from "@/lib/imager";
import type { DataTableColumn } from "@/types/common";
import { userDetailHref } from "./user-list-navigation";
export interface UserRow {
id: number;
username: string;
mail: string | null;
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",
};
export function getUserColumns(
canEdit: boolean,
listQuery: string,
): DataTableColumn<UserRow>[] {
const cols: DataTableColumn<UserRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "username",
label: "User",
sortable: true,
render: (_, row) => (
<Link
href={userDetailHref(row.id, "show", listQuery)}
className="flex items-center gap-2.5 hover:opacity-80"
>
<img
src={getAvatarUrl(row.look, {
size: "m",
direction: 2,
headDirection: 2,
})}
alt=""
width={64}
height={110}
className="h-[110px] w-16 shrink-0"
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={userDetailHref(row.id, "show", listQuery)}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{canEdit && (
<Button variant="ghost" size="icon" asChild title="Edit">
<Link href={userDetailHref(row.id, "edit", listQuery)}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
)}
</div>
),
},
];
return cols;
}
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { userDetailHref, userListHref } from "./user-list-navigation";
it("preserves search filters and pagination through detail and edit links", () => {
const query = "search=Mario%20Rossi&page=3&filter_online=1&perPage=50";
for (const mode of ["show", "edit"] as const) {
const link = new URL(
userDetailHref(9, mode, query),
"https://example.test",
);
expect(userListHref(link.searchParams.get("list") ?? "")).toBe(
"/admin/users?search=Mario+Rossi&page=3&filter_online=1&perPage=50",
);
}
});
it("always returns a local users-list URL", () => {
expect(userListHref("")).toBe("/admin/users");
expect(userListHref("https://other.example")).toMatch(/^\/admin\/users\?/);
});
@@ -0,0 +1,11 @@
export function userListHref(query: string): string {
const search = new URLSearchParams(query).toString();
return search ? `/admin/users?${search}` : "/admin/users";
}
export function userDetailHref(
id: number,
mode: "show" | "edit",
query: string,
): string {
return `/admin/users/${mode}/${id}?list=${encodeURIComponent(query)}`;
}
+29 -311
View File
@@ -1,18 +1,8 @@
"use client";
import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
bulkAdjustCurrency,
bulkBan,
bulkGiveBadge,
bulkUnban,
} from "@/actions/bulk-users";
import { useSearchParams } from "next/navigation";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -31,164 +21,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getAvatarUrl } from "@/lib/imager";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface UserRow {
id: number;
username: string;
mail: string | null;
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",
};
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: "m",
direction: 2,
headDirection: 2,
})}
alt=""
width={64}
height={110}
className="h-[110px] w-16 shrink-0"
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;
}
import type { PaginatedResult } from "@/types/common";
import { useUserBulkActions } from "./use-user-bulk-actions";
import { getUserColumns, type UserRow } from "./user-columns";
interface UsersTableProps {
data: PaginatedResult<UserRow>;
@@ -196,144 +31,27 @@ interface UsersTableProps {
canBan: boolean;
}
type PendingDialog =
| { 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("");
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));
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 === "give-currency") {
setPending({ kind: "give-currency", 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 confirmGiveCurrency() {
if (pending?.kind !== "give-currency") return;
setBusy(true);
try {
const result = await bulkAdjustCurrency({
userIds: pending.ids,
amount: Number(currencyAmount),
type: currencyType,
});
if (result.ok) {
const data = result.data as {
adjusted: number;
total: number;
failedIds: { userId: number; reason: string }[];
};
reportResult(
`Adjusted ${currencyAmount} ${currencyType}`,
data.adjusted,
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 searchParams = useSearchParams();
const {
pending,
setPending,
busy,
banReason,
setBanReason,
banDuration,
setBanDuration,
currencyType,
setCurrencyType,
currencyAmount,
setCurrencyAmount,
badgeCode,
setBadgeCode,
handleBulkAction,
confirmBan,
confirmGiveCurrency,
confirmGiveBadge,
} = useUserBulkActions();
const bulkActions: {
label: string;
value: string;
@@ -352,7 +70,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
<>
<DataTable
data={data}
columns={getColumns(canEdit)}
columns={getUserColumns(canEdit, searchParams.toString())}
searchPlaceholder="Search by username, email or IP..."
presets={[
{ label: "Online now", params: { filter_online: "1" } },
@@ -366,9 +84,9 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
<Dialog
open={pending?.kind === "ban"}
onOpenChange={(o) => !o && setPending(null)}
onOpenChange={(o) => !o && !busy && setPending(null)}
>
<DialogContent>
<DialogContent className="max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
@@ -421,7 +139,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
open={pending?.kind === "give-currency"}
onOpenChange={(o) => !o && setPending(null)}
>
<DialogContent>
<DialogContent className="max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Adjust currency for{" "}
@@ -492,7 +210,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
open={pending?.kind === "give-badge"}
onOpenChange={(o) => !o && setPending(null)}
>
<DialogContent>
<DialogContent className="max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Give badge to{" "}