This commit is contained in:
1 parent
21a61068fb
commit
60278b9e71
38 files changed
+3452
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export default async function EmulatorErrorsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>
|
||||||
|
}) {
|
||||||
|
const { session, permissions } = await getAdminContext()
|
||||||
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||||
|
redirect('/admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Math.max(1, Number(params.page) || 1)
|
||||||
|
const perPage = 50
|
||||||
|
|
||||||
|
const [errors, total] = await Promise.all([
|
||||||
|
prisma.emulatorErrors.findMany({
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
skip: (page - 1) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
}),
|
||||||
|
prisma.emulatorErrors.count(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const lastPage = Math.ceil(total / perPage)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{total} total errors — showing page {page} of {lastPage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground">No emulator errors found</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<div key={error.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">#{error.id}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(error.timestamp * 1000).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
||||||
|
{Buffer.from(error.stacktrace).toString('utf-8')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{lastPage > 1 && (
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{page > 1 && (
|
||||||
|
<a href={`?page=${page - 1}`} className="text-sm text-primary hover:underline">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {lastPage}
|
||||||
|
</span>
|
||||||
|
{page < lastPage && (
|
||||||
|
<a href={`?page=${page + 1}`} className="text-sm text-primary hover:underline">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function HealthCheckClient() {
|
||||||
|
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
|
||||||
|
async function checkEmulator() {
|
||||||
|
setChecking(true)
|
||||||
|
setStatus('checking')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/devops/health')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setStatus(data.emulator ? 'online' : 'offline')
|
||||||
|
} else {
|
||||||
|
setStatus('offline')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('offline')
|
||||||
|
} finally {
|
||||||
|
setChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
|
useEffect(() => {
|
||||||
|
checkEmulator()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={checkEmulator}
|
||||||
|
disabled={checking}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -89,6 +89,8 @@ function getNavGroups(t: (key: string) => string) {
|
|||||||
{ href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> },
|
{ href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> },
|
||||||
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
|
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
|
||||||
{ href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> },
|
{ href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> },
|
||||||
|
{ href: "/admin/online", label: "Online Users", icon: <Users size={16} /> },
|
||||||
|
{ href: "/admin/moderation", label: "Moderation", icon: <Shield size={16} /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,6 +133,9 @@ function getNavGroups(t: (key: string) => string) {
|
|||||||
{ href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> },
|
{ href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> },
|
||||||
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
|
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
|
||||||
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
|
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
|
||||||
|
{ href: "/admin/logs/audit", label: "Audit Log", icon: <FileText size={16} /> },
|
||||||
|
{ href: "/admin/analytics", label: "Analytics", icon: <LayoutDashboard size={16} /> },
|
||||||
|
{ href: "/admin/devops", label: "DevOps", icon: <Monitor size={16} /> },
|
||||||
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> },
|
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> },
|
||||||
{ href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> },
|
{ href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> },
|
||||||
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> },
|
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> },
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
||||||
|
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||||
|
import { getAuditLogs } from '@/lib/services/audit'
|
||||||
|
import { AuditTable } from './audit-table'
|
||||||
|
|
||||||
|
export default async function AuditLogsPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const sp = new URLSearchParams(params)
|
||||||
|
const { search, perPage, page } = parseListParams(sp)
|
||||||
|
|
||||||
|
const result = await getAuditLogs({ search, page, 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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/admin/data-table'
|
||||||
|
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||||
|
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
|
interface ChatLogRow {
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ChatTableProps {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { fetchAdminList } from '@/lib/admin-list'
|
||||||
|
import { PERMS } from '@/lib/permissions'
|
||||||
|
import { ChatTable } from './chat-table'
|
||||||
|
|
||||||
|
export default async function ChatLogsPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/admin/data-table'
|
||||||
|
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||||
|
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
|
interface CommandLogRow {
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CommandsTableProps {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { fetchAdminList } from '@/lib/admin-list'
|
||||||
|
import { PERMS } from '@/lib/permissions'
|
||||||
|
import { CommandsTable } from './commands-table'
|
||||||
|
|
||||||
|
export default async function CommandLogsPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { fetchAdminList } from '@/lib/admin-list'
|
||||||
|
import { PERMS } from '@/lib/permissions'
|
||||||
|
import { TradesTable } from './trades-table'
|
||||||
|
|
||||||
|
export default async function TradeLogsPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/admin/data-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { formatTimestamp } from '@/lib/admin-helpers'
|
||||||
|
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
|
interface TradeLogRow {
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TradesTableProps {
|
||||||
|
data: PaginatedResult<TradeLogRow>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TradesTable({ data }: TradesTableProps) {
|
||||||
|
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||||
|
import { ModActionsClient } from './mod-actions-client'
|
||||||
|
|
||||||
|
export default async function QuickActionsPage() {
|
||||||
|
const { session, permissions } = await getAdminContext()
|
||||||
|
if (!canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank)) redirect('/admin')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Quick Actions</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Fast moderation tools — kick, mute, alert, broadcast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ModActionsClient />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
|||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { CfhDetail } from './cfh-detail'
|
||||||
|
|
||||||
|
export default async function CfhDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, permissions } = await getAdminContext()
|
||||||
|
if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const ticketId = Number(id)
|
||||||
|
if (Number.isNaN(ticketId)) notFound()
|
||||||
|
|
||||||
|
const ticket = await prisma.supportTickets.findUnique({ where: { id: ticketId } })
|
||||||
|
if (!ticket) notFound()
|
||||||
|
|
||||||
|
const canEdit = canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank)
|
||||||
|
|
||||||
|
// Fetch sender, reported, mod info
|
||||||
|
const userIds = [ticket.senderId, ticket.reportedId, ticket.modId].filter(Boolean)
|
||||||
|
const users =
|
||||||
|
userIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
rank: true,
|
||||||
|
online: true,
|
||||||
|
ipRegister: true,
|
||||||
|
mail: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const userMap = Object.fromEntries(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
// Get ban/mute history for reported user
|
||||||
|
const reportedId = ticket.reportedId
|
||||||
|
const [banCount, recentBans] = reportedId
|
||||||
|
? await Promise.all([
|
||||||
|
prisma.ban.count({ where: { userId: reportedId } }),
|
||||||
|
prisma.ban.findMany({
|
||||||
|
where: { userId: reportedId },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [0, []]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CfhDetail
|
||||||
|
ticket={{
|
||||||
|
id: ticket.id,
|
||||||
|
state: ticket.state,
|
||||||
|
type: ticket.type,
|
||||||
|
timestamp: ticket.timestamp,
|
||||||
|
issue: ticket.issue,
|
||||||
|
roomId: ticket.roomId,
|
||||||
|
category: ticket.category,
|
||||||
|
score: ticket.score,
|
||||||
|
}}
|
||||||
|
sender={userMap[ticket.senderId] ?? null}
|
||||||
|
reported={userMap[ticket.reportedId] ?? null}
|
||||||
|
moderator={userMap[ticket.modId] ?? null}
|
||||||
|
banHistory={{
|
||||||
|
count: banCount,
|
||||||
|
recent: recentBans.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
reason: b.banReason,
|
||||||
|
type: b.type,
|
||||||
|
staff: `Staff #${b.userStaffId}`,
|
||||||
|
timestamp: b.timestamp,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
canEdit={canEdit}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
export default function OnlineLoading() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
|||||||
|
import { fetchAdminList } from '@/lib/admin-list'
|
||||||
|
import { PERMS } from '@/lib/permissions'
|
||||||
|
import { OnlineTable } from './online-table'
|
||||||
|
|
||||||
|
export default async function OnlineUsersPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
searchParams: Promise<Record<string, string>>
|
||||||
|
}) {
|
||||||
|
const result = await fetchAdminList(
|
||||||
|
{
|
||||||
|
permission: PERMS.USERS_VIEW,
|
||||||
|
model: 'user',
|
||||||
|
searchFields: ['username', 'motto'],
|
||||||
|
baseWhere: { online: '1' },
|
||||||
|
defaultPerPage: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
motto: true,
|
||||||
|
look: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Online Users</h1>
|
||||||
|
<p className="text-muted-foreground">Currently online users ({result.total} connected)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OnlineTable
|
||||||
|
data={{
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
perPage: result.perPage,
|
||||||
|
lastPage: result.lastPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
|||||||
|
import net from 'node:net'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { env } from '@/env'
|
||||||
|
import { withAdmin } from '@/lib/api-handler'
|
||||||
|
import { PERMS } from '@/lib/permissions'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
function checkRcon(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const host = env.RCON_HOST
|
||||||
|
const port = env.RCON_PORT
|
||||||
|
|
||||||
|
const socket = new net.Socket()
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
socket.connect(port, host, () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
socket.destroy()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
|
||||||
|
// DB health
|
||||||
|
let dbOk = false
|
||||||
|
let dbLatency = 0
|
||||||
|
try {
|
||||||
|
const start = Date.now()
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
dbLatency = Date.now() - start
|
||||||
|
dbOk = true
|
||||||
|
} catch {
|
||||||
|
dbOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emulator health (RCON TCP check)
|
||||||
|
const emulator = await checkRcon()
|
||||||
|
|
||||||
|
// Online users
|
||||||
|
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
database: dbOk,
|
||||||
|
dbLatency,
|
||||||
|
emulator,
|
||||||
|
onlineUsers,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
interface ChartDataPoint { label: string; value: number }
|
||||||
|
|
||||||
|
export function DashboardChart({ title, data, description }: { title: string; data: ChartDataPoint[]; color?: string; description?: string }) {
|
||||||
|
const max = Math.max(1, ...data.map((point) => point.value))
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium">{title}</CardTitle>{description && <p className="text-xs text-muted-foreground">{description}</p>}</CardHeader>
|
||||||
|
<CardContent><div className="flex h-[200px] items-end gap-1" aria-label={title}>{data.map((point) => <div key={point.label} className="flex min-w-0 flex-1 flex-col items-center gap-1"><span className="text-[10px] text-muted-foreground">{point.value}</span><div className="w-full rounded-t bg-[var(--admin-accent)]" style={{ height: `${Math.max(2, (point.value / max) * 150)}px` }} title={`${point.label}: ${point.value}`} /><span className="max-w-full truncate text-[9px] text-muted-foreground">{point.label}</span></div>)}</div></CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
interface PeakHoursData {
|
||||||
|
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
|
||||||
|
grid: number[][]
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
function getColor(value: number, max: number) {
|
||||||
|
if (max === 0 || value === 0) return 'bg-muted'
|
||||||
|
const ratio = value / max
|
||||||
|
if (ratio < 0.25) return 'bg-[var(--admin-success)]/20'
|
||||||
|
if (ratio < 0.5) return 'bg-[var(--admin-success)]/55'
|
||||||
|
if (ratio < 0.75) return 'bg-[var(--admin-success)] dark:bg-[var(--admin-success)]'
|
||||||
|
return 'bg-[var(--admin-success-strong,var(--admin-success))]'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
User activity by day & hour (last 7 days)
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[500px]">
|
||||||
|
{/* Hour labels */}
|
||||||
|
<div className="flex gap-px ml-10 mb-1">
|
||||||
|
{HOURS.map((h) => (
|
||||||
|
<div key={h} className="flex-1 text-center text-[10px] text-muted-foreground">
|
||||||
|
{h % 3 === 0 ? `${h}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
{DAYS.map((day, dayIdx) => (
|
||||||
|
<div key={day} className="flex items-center gap-px mb-px">
|
||||||
|
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
{HOURS.map((hour) => {
|
||||||
|
const count = data.grid[dayIdx]?.[hour] ?? 0
|
||||||
|
return (
|
||||||
|
<Tooltip key={hour}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`flex-1 aspect-square rounded-sm ${getColor(count, data.max)} transition-colors min-w-[14px]`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
{day} {hour}:00 — {count} users
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-2 mt-3 ml-10">
|
||||||
|
<span className="text-[10px] text-muted-foreground">Less</span>
|
||||||
|
<div className="h-3 w-3 rounded-sm bg-muted" />
|
||||||
|
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" />
|
||||||
|
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" />
|
||||||
|
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" />
|
||||||
|
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
interface RevenueDataPoint { label: string; credits: number; points: number }
|
||||||
|
|
||||||
|
export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
|
||||||
|
const max = Math.max(1, ...data.flatMap((point) => [point.credits, point.points]))
|
||||||
|
return <Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Catalog Spending</CardTitle></CardHeader><CardContent><div className="space-y-3">{data.map((point) => <div key={point.label} className="grid grid-cols-[3rem_1fr] items-center gap-2 text-xs"><span>{point.label}</span><div className="space-y-1"><div className="h-2 rounded bg-[var(--admin-warning)]" style={{ width: `${Math.max(2, point.credits / max * 100)}%` }} title={`${point.credits} credits`} /><div className="h-2 rounded bg-[var(--admin-accent)]" style={{ width: `${Math.max(2, point.points / max * 100)}%` }} title={`${point.points} points`} /></div></div>)}</div></CardContent></Card>
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
description?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
href?: string
|
||||||
|
/** Percentage change vs previous period. Positive = up, negative = down, undefined = no trend */
|
||||||
|
trend?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({ title, value, description, icon, href, trend }: StatsCardProps) {
|
||||||
|
const content = (
|
||||||
|
<Card
|
||||||
|
className={
|
||||||
|
href
|
||||||
|
? 'transition-all duration-200 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
{trend !== undefined && <TrendBadge value={trend} />}
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return <Link href={href}>{content}</Link>
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendBadge({ value }: { value: number }) {
|
||||||
|
if (value === 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
0%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUp = value > 0
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
|
||||||
|
isUp ? 'text-[var(--admin-success)]' : 'text-[var(--admin-error)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUp ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||||
|
{isUp ? '+' : ''}
|
||||||
|
{value}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type AvatarOptions, getAvatarUrl } from '@/lib/imager'
|
||||||
|
|
||||||
|
interface AvatarImageProps {
|
||||||
|
figure: string
|
||||||
|
options?: AvatarOptions
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
alt: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarImage({
|
||||||
|
figure,
|
||||||
|
options,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
alt,
|
||||||
|
className = '',
|
||||||
|
}: AvatarImageProps) {
|
||||||
|
// Auto-pick the smallest size that still looks crisp at the requested
|
||||||
|
// dimensions, unless the caller passed an explicit size.
|
||||||
|
const max = Math.max(width, height)
|
||||||
|
const autoSize: 's' | 'm' | 'l' =
|
||||||
|
options?.size ?? (options?.headOnly ? 'l' : max <= 40 ? 's' : max <= 80 ? 'm' : 'l')
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={getAvatarUrl(figure, { ...options, size: autoSize })}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ maxWidth: width, maxHeight: height }}
|
||||||
|
className={`image-pixelated object-contain ${className}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -101,6 +101,15 @@ export class RconClient {
|
|||||||
forwardUser(userId: number, roomId: number) {
|
forwardUser(userId: number, roomId: number) {
|
||||||
return this.send("forwarduser", { user_id: userId, room_id: roomId });
|
return this.send("forwarduser", { user_id: userId, room_id: roomId });
|
||||||
}
|
}
|
||||||
|
kickAll(roomId: number) {
|
||||||
|
return this.send("kickall", { room_id: roomId });
|
||||||
|
}
|
||||||
|
hotelAlert(message: string) {
|
||||||
|
return this.send("hotelalert", { message });
|
||||||
|
}
|
||||||
|
staffAlert(message: string) {
|
||||||
|
return this.send("staffalert", { message });
|
||||||
|
}
|
||||||
executeCommand(userId: number, command: string) {
|
executeCommand(userId: number, command: string) {
|
||||||
return this.send("executecommand", { user_id: userId, command });
|
return this.send("executecommand", { user_id: userId, command });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user