Files
EpicNext-Cms/src/app/admin/page.tsx
T
Simo 4dfe698009 Close remaining web gaps: rich profile, login history, lightbox/slider, flash client, admin chatlog/DJ/chart/WYSIWYG, niche API
- Rich profile (/u/[username]): wallet (credits/duckets/diamonds), friends
  grid (messenger_friendships), and owned rooms sections.
- Login history: new website_login_logs table (model + migration 0007),
  recorded on every successful sign-in (ip + user-agent), surfaced on a new
  /settings/sessions page (with failed-attempt list from failed_logins).
- Photos lightbox + home article slider (client components, no Swiper dep).
- /client/flash launcher (SSO ticket like the Nitro page).
- Admin: private chatlogs section in /admin/logs, /admin/radio/moderation
  (shout moderation), a "users by rank" inline bar chart on the dashboard,
  and a TinyMCE rich-text editor on the article admin forms.
- Niche API: /api/values/[id], /api/guilds(+/[id]), /api/radio/auto-play.

Verified live (prod, amx_test): login recorded → /settings/sessions shows
it with device; profile renders wallet/friends/rooms; dashboard chart +
private-chat logs + /client/flash + /api/guilds all OK. Reverted test data.
tsc 0, vitest 49/49, next build 0.
2026-06-29 18:26:34 +02:00

162 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
type RankCount = { rank: number; count: number };
export default async function AdminDashboard() {
let users = -1;
let online = 0;
let articles = 0;
let bans = 0;
try {
[users, online, articles, bans] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { online: "1" } }),
prisma.websiteArticles.count(),
prisma.ban.count({ where: { banExpire: { gt: Math.floor(Date.now() / 1000) } } }),
]);
} catch {
// DB unavailable — show zeroed stats instead of a 500.
}
const dbOk = users >= 0;
// Users-by-rank breakdown (small inline bar chart, no chart library).
const rankGroups = await prisma.user
.groupBy({ by: ["rank"], _count: { _all: true }, orderBy: { rank: "desc" } })
.catch(() => [] as Array<{ rank: number; _count: { _all: number } }>);
const rankCounts: RankCount[] = rankGroups
.map((g) => ({ rank: g.rank, count: g._count._all }))
.sort((a, b) => b.count - a.count);
const maxRankCount = rankCounts.reduce((m, r) => Math.max(m, r.count), 0);
const activity = await prisma.staffActivities
.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>);
const staffIds = Array.from(new Set(activity.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const u = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(u.map((x) => [x.id, x.username]));
}
return (
<main>
<h1>Dashboard</h1>
<div className="admin-stats">
<StatusCard
label="Players online"
value={online}
hint={dbOk ? `${users} accounts` : "DB unavailable"}
state={dbOk ? "neutral" : "danger"}
icon="\u{1F464}"
/>
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
<StatusCard
label="Active bans"
value={dbOk ? bans : "—"}
state={bans > 0 ? "warn" : "neutral"}
icon="\u{1F528}"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
state={dbOk ? "ok" : "danger"}
icon="\u{1F5C4}️"
/>
</div>
<section className="admin-section">
<h2 style={{ margin: 0 }}>Users by rank</h2>
{rankCounts.length === 0 ? (
<p className="muted" style={{ marginTop: "0.7rem" }}>
{dbOk ? "No accounts to break down yet." : "DB unavailable."}
</p>
) : (
<div style={{ display: "grid", gap: "0.5rem", marginTop: "0.8rem" }}>
{rankCounts.map((r) => {
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
return (
<div
key={r.rank}
style={{ display: "grid", gridTemplateColumns: "5.5rem 1fr 3rem", alignItems: "center", gap: "0.6rem" }}
>
<span className="muted" style={{ fontSize: "0.82rem", whiteSpace: "nowrap" }}>
Rank {r.rank}
</span>
<div
style={{
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
borderRadius: "var(--radius-sm)",
height: "0.85rem",
overflow: "hidden",
}}
role="img"
aria-label={`Rank ${r.rank}: ${r.count} accounts`}
>
<div
style={{
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
height: "100%",
background: "var(--color-primary, #eeb425)",
borderRadius: "var(--radius-sm)",
}}
/>
</div>
<strong style={{ fontSize: "0.85rem", textAlign: "right" }}>{r.count}</strong>
</div>
);
})}
</div>
)}
</section>
<section className="admin-section">
<div style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
<h2 style={{ margin: 0 }}>Recent staff activity</h2>
<Link href="/admin/commandocentrum" className="muted" style={{ fontSize: "0.82rem" }}>
View all →
</Link>
</div>
{activity.length === 0 ? (
<p className="muted">No staff activity logged yet.</p>
) : (
<table style={{ marginTop: "0.7rem" }}>
<thead>
<tr>
<th>When</th>
<th>Staff</th>
<th>Action</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{activity.map((a) => (
<tr key={String(a.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{fromDate(a.createdAt)}
</td>
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
<td>
<span className="admin-badge">{a.action}</span>
</td>
<td>{a.description}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}