style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+91 -85
View File
@@ -7,105 +7,111 @@ import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface AuditRow {
id: number;
username: string;
action: string;
target: string;
targetId: number | null;
diff: string | null;
createdAt: string;
id: number;
username: string;
action: string;
target: string;
targetId: number | null;
diff: string | null;
createdAt: string;
}
function DiffCell({ diff }: { diff: string | null }) {
const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span>;
const [open, setOpen] = useState(false);
if (!diff) return <span className="text-muted-foreground">-</span>;
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">-</span>;
}
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">-</span>;
}
const keys = Object.keys(parsed);
if (keys.length === 0) return <span className="text-muted-foreground">-</span>;
const keys = Object.keys(parsed);
if (keys.length === 0)
return <span className="text-muted-foreground">-</span>;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes`;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: `${keys.length} changes`;
return (
<div>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-normal"
onClick={() => setOpen(!open)}
>
{summary}
</Button>
{open && (
<div className="mt-1 space-y-0.5 text-xs">
{keys.map((k) => (
<div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span>
<span className="text-[var(--admin-error)] line-through">{String(parsed[k].from ?? "∅")}</span>
<span>→</span>
<span className="text-[var(--admin-success)]">{String(parsed[k].to ?? "∅")}</span>
</div>
))}
</div>
)}
</div>
);
return (
<div>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-normal"
onClick={() => setOpen(!open)}
>
{summary}
</Button>
{open && (
<div className="mt-1 space-y-0.5 text-xs">
{keys.map((k) => (
<div key={k} className="flex gap-1">
<span className="font-medium">{k}:</span>
<span className="text-[var(--admin-error)] line-through">
{String(parsed[k].from ?? "∅")}
</span>
<span>→</span>
<span className="text-[var(--admin-success)]">
{String(parsed[k].to ?? "∅")}
</span>
</div>
))}
</div>
)}
</div>
);
}
const columns: DataTableColumn<AuditRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User", sortable: true },
{
key: "action",
label: "Action",
sortable: true,
render: (value) => {
const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes("delete") || v.includes("remove") || v.includes("ban")) variant = "destructive";
return <Badge variant={variant}>{v}</Badge>;
},
},
{ key: "target", label: "Target", sortable: true },
{
key: "targetId",
label: "Target ID",
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
},
{
key: "diff",
label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />,
},
{
key: "createdAt",
label: "Date",
sortable: true,
render: (value) => <span>{String(value) || "N/A"}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User", sortable: true },
{
key: "action",
label: "Action",
sortable: true,
render: (value) => {
const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary";
if (v.includes("create") || v.includes("add")) variant = "default";
if (v.includes("delete") || v.includes("remove") || v.includes("ban"))
variant = "destructive";
return <Badge variant={variant}>{v}</Badge>;
},
},
{ key: "target", label: "Target", sortable: true },
{
key: "targetId",
label: "Target ID",
render: (value) => <span>{value != null ? String(value) : "-"}</span>,
},
{
key: "diff",
label: "Details",
render: (_value, row) => <DiffCell diff={row.diff} />,
},
{
key: "createdAt",
label: "Date",
sortable: true,
render: (value) => <span>{String(value) || "N/A"}</span>,
},
];
interface AuditTableProps {
data: PaginatedResult<AuditRow>;
data: PaginatedResult<AuditRow>;
}
export function AuditTable({ data }: AuditTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by action or target..."
exportUrl="/api/admin/export?type=audit"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AuditLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+32 -32
View File
@@ -5,44 +5,44 @@ import { getAuditLogs } from "@/lib/services/audit";
import { AuditTable } from "./audit-table";
export default async function AuditLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect("/admin");
}
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp);
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp);
const result = await getAuditLogs({ search, page, perPage });
const result = await getAuditLogs({ search, page, perPage });
const pagination = calcPagination(result.total, result.page, result.perPage);
const pagination = calcPagination(result.total, result.page, result.perPage);
const rows = result.rows.map((r) => ({
id: r.id,
username: r.username,
action: r.action,
target: r.target,
targetId: r.targetId,
diff: r.diff,
createdAt: r.createdAt,
}));
const rows = result.rows.map((r) => ({
id: r.id,
username: r.username,
action: r.action,
target: r.target,
targetId: r.targetId,
diff: r.diff,
createdAt: r.createdAt,
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
<AuditTable data={{ rows, ...pagination }} />
</div>
);
<AuditTable data={{ rows, ...pagination }} />
</div>
);
}
+30 -28
View File
@@ -5,41 +5,43 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface ChatLogRow {
id: number;
username: string;
message: string;
timestamp: number;
roomId: number;
id: number;
username: string;
message: string;
timestamp: number;
roomId: number;
}
const columns: DataTableColumn<ChatLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{
key: "message",
label: "Message",
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "roomId", label: "Room ID", sortable: true },
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{
key: "message",
label: "Message",
render: (value) => (
<span className="max-w-[300px] truncate block">{String(value)}</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "roomId", label: "Room ID", sortable: true },
];
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>;
data: PaginatedResult<ChatLogRow>;
}
export function ChatTable({ data }: ChatTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ChatLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+39 -39
View File
@@ -3,47 +3,47 @@ import { PERMS } from "@/lib/permissions";
import { ChatTable } from "./chat-table";
export default async function ChatLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "chatLog",
searchFields: ["message", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
message: l.message,
timestamp: l.timestamp,
roomId: l.roomId,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "chatLog",
searchFields: ["message", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
message: l.message,
timestamp: l.timestamp,
roomId: l.roomId,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
<ChatTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<ChatTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+32 -30
View File
@@ -5,43 +5,45 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CommandLogRow {
id: number;
username: string;
command: string;
params: string;
timestamp: number;
id: number;
username: string;
command: string;
params: string;
timestamp: number;
}
const columns: DataTableColumn<CommandLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || "-"}</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">
{String(value) || "-"}
</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
interface CommandsTableProps {
data: PaginatedResult<CommandLogRow>;
data: PaginatedResult<CommandLogRow>;
}
export function CommandsTable({ data }: CommandsTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CommandLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+39 -39
View File
@@ -3,47 +3,47 @@ import { PERMS } from "@/lib/permissions";
import { CommandsTable } from "./commands-table";
export default async function CommandLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "commandLog",
searchFields: ["command", "params", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
command: l.command,
params: l.params,
timestamp: l.timestamp,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "commandLog",
searchFields: ["command", "params", "user.username"],
defaultPerPage: 50,
include: { user: { select: { username: true } } },
mapRow: (l) => ({
id: l.id,
username: l.user.username,
command: l.command,
params: l.params,
timestamp: l.timestamp,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
<CommandsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<CommandsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+202 -172
View File
@@ -1,199 +1,229 @@
import { getTranslations } from "next-intl/server";
import { ScrollText } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminStaffActivities({
searchParams,
searchParams,
}: {
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
}) {
const t = await getTranslations("pages.admin.logs");
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({ q, staffId, action, authorizationOnly });
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({
q,
staffId,
action,
authorizationOnly,
});
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const staffIds = Array.from(new Set(activities.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const staffIds = Array.from(
new Set(activities.map((a) => Number(a.userId))),
).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({
where: { id: { in: staffIds } },
select: { id: true, username: true },
})
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0);
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).length;
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).length;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard label={t("onThisPage")} value={activities.length} icon="📄" />
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
label={t("onThisPage")}
value={activities.length}
icon="📄"
/>
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="authorization" value="1" defaultChecked={authorizationOnly} />
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="authorization"
value="1"
defaultChecked={authorizationOnly}
/>
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">{t("activities", { count: total })}</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</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)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#{String(a.targetId)}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activities", { count: total })}
</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</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)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#
{String(a.targetId)}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
}
+27 -24
View File
@@ -1,28 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function TradeLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+42 -42
View File
@@ -3,50 +3,50 @@ import { PERMS } from "@/lib/permissions";
import { TradesTable } from "./trades-table";
export default async function TradeLogsPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "trade",
searchFields: ["userOne.username", "userTwo.username"],
defaultPerPage: 50,
include: {
userOne: { select: { username: true } },
userTwo: { select: { username: true } },
},
mapRow: (t) => ({
id: t.id,
userOneName: t.userOne.username,
userTwoName: t.userTwo.username,
status: t.status,
timestamp: t.timestamp,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.LOGS_VIEW,
model: "trade",
searchFields: ["userOne.username", "userTwo.username"],
defaultPerPage: 50,
include: {
userOne: { select: { username: true } },
userTwo: { select: { username: true } },
},
mapRow: (t) => ({
id: t.id,
userOneName: t.userOne.username,
userTwoName: t.userTwo.username,
status: t.status,
timestamp: t.timestamp,
}),
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
<TradesTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
<TradesTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+37 -26
View File
@@ -6,39 +6,50 @@ import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface TradeLogRow {
id: number;
userOneName: string;
userTwoName: string;
status: string;
timestamp: number;
id: number;
userOneName: string;
userTwoName: string;
status: string;
timestamp: number;
}
const columns: DataTableColumn<TradeLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "userOneName", label: "User One" },
{ key: "userTwoName", label: "User Two" },
{
key: "status",
label: "Status",
sortable: true,
render: (value) => {
const v = String(value);
const variant = v === "completed" ? "default" : v === "cancelled" ? "destructive" : "secondary";
return <Badge variant={variant}>{v || "unknown"}</Badge>;
},
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: "id", label: "ID", sortable: true },
{ key: "userOneName", label: "User One" },
{ key: "userTwoName", label: "User Two" },
{
key: "status",
label: "Status",
sortable: true,
render: (value) => {
const v = String(value);
const variant =
v === "completed"
? "default"
: v === "cancelled"
? "destructive"
: "secondary";
return <Badge variant={variant}>{v || "unknown"}</Badge>;
},
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
interface TradesTableProps {
data: PaginatedResult<TradeLogRow>;
data: PaginatedResult<TradeLogRow>;
}
export function TradesTable({ data }: TradesTableProps) {
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />;
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username..."
/>
);
}