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.
This commit is contained in:
Simo committed 2026-06-29 18:26:34 +02:00
1 parent f7b3845131
commit 4dfe698009
25 files changed
+1463 -64

No files matched your search

+60 -4
View File
@@ -8,6 +8,8 @@ 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;
@@ -25,6 +27,15 @@ export default async function AdminDashboard() {
}
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>>);
@@ -47,23 +58,68 @@ export default async function AdminDashboard() {
value={online}
hint={dbOk ? `${users} accounts` : "DB unavailable"}
state={dbOk ? "neutral" : "danger"}
icon="👤"
icon="\u{1F464}"
/>
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
<StatusCard
label="Active bans"
value={dbOk ? bans : "—"}
state={bans > 0 ? "warn" : "neutral"}
icon="🔨"
icon="\u{1F528}"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
state={dbOk ? "ok" : "danger"}
icon="🗄️"
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>