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:
1 parent
f7b3845131
commit
4dfe698009
25 files changed
+1463
-64
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { RichText } from "@/components/admin/rich-text";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,7 +31,7 @@ export default async function EditArticle({
|
||||
<input name="title" defaultValue={article.title} required />
|
||||
<input name="image" defaultValue={article.image} placeholder="Image URL" />
|
||||
<textarea name="shortStory" defaultValue={article.shortStory} rows={2} />
|
||||
<textarea name="fullStory" defaultValue={article.fullStory} rows={10} />
|
||||
<RichText name="fullStory" defaultValue={article.fullStory} rows={10} />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { createArticle } from "@/actions/admin-articles";
|
||||
import { RichText } from "@/components/admin/rich-text";
|
||||
|
||||
export default function NewArticle() {
|
||||
return (
|
||||
@@ -12,7 +13,7 @@ export default function NewArticle() {
|
||||
<input name="title" placeholder="Title" required />
|
||||
<input name="image" placeholder="Image URL" />
|
||||
<textarea name="shortStory" placeholder="Short story (summary)" rows={2} />
|
||||
<textarea name="fullStory" placeholder="Full story" rows={10} />
|
||||
<RichText name="fullStory" placeholder="Full story" rows={10} />
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -7,7 +7,10 @@ const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
|
||||
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma
|
||||
// generates no delegate for them — they are read via $queryRaw against the real
|
||||
// emulator columns. alert_logs has a proper delegate (prisma.alertLogs).
|
||||
// emulator columns. chatlogs_private has a delegate (prisma.chatlogsPrivate) but
|
||||
// is read here via $queryRaw too, mirroring the room-chat section and selecting
|
||||
// defensively so a column mismatch degrades to an empty list rather than a 500.
|
||||
// alert_logs has a proper delegate (prisma.alertLogs).
|
||||
|
||||
type ChatlogRoomRow = {
|
||||
room_id: number;
|
||||
@@ -17,6 +20,13 @@ type ChatlogRoomRow = {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type ChatlogPrivateRow = {
|
||||
user_from_id: number;
|
||||
user_to_id: number;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type CommandlogRow = {
|
||||
user_id: number;
|
||||
timestamp: number;
|
||||
@@ -50,6 +60,21 @@ export default async function AdminLogs() {
|
||||
chatlogs = [];
|
||||
}
|
||||
|
||||
// Private (whisper) chat. Defensive SELECT + try/catch — the table may be
|
||||
// absent or have differing columns on some emulators, in which case we show
|
||||
// an empty section instead of crashing the page.
|
||||
let privateChatlogs: ChatlogPrivateRow[] = [];
|
||||
try {
|
||||
privateChatlogs = await prisma.$queryRaw<ChatlogPrivateRow[]>`
|
||||
SELECT user_from_id, user_to_id, message, timestamp
|
||||
FROM chatlogs_private
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
} catch {
|
||||
privateChatlogs = [];
|
||||
}
|
||||
|
||||
let commandlogs: CommandlogRow[] = [];
|
||||
try {
|
||||
commandlogs = await prisma.$queryRaw<CommandlogRow[]>`
|
||||
@@ -81,6 +106,7 @@ export default async function AdminLogs() {
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Room chat" value={chatlogs.length} icon="💬" />
|
||||
<StatusCard label="Private chat" value={privateChatlogs.length} icon="🔒" />
|
||||
<StatusCard label="Commands" value={commandlogs.length} icon="⌨️" />
|
||||
<StatusCard label="Alerts" value={alertLogs.length} icon="🔔" />
|
||||
</div>
|
||||
@@ -117,6 +143,36 @@ export default async function AdminLogs() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Private chat ({privateChatlogs.length})</h3>
|
||||
{privateChatlogs.length === 0 ? (
|
||||
<p className="muted">No private chat logs.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{privateChatlogs.map((c, i) => (
|
||||
<tr key={`${c.timestamp}-${c.user_from_id}-${c.user_to_id}-${i}`}>
|
||||
<td className="muted">{fromUnix(c.timestamp)}</td>
|
||||
<td>{String(c.user_from_id)}</td>
|
||||
<td>{c.user_to_id ? String(c.user_to_id) : "—"}</td>
|
||||
<td>{c.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Commands ({commandlogs.length})</h3>
|
||||
{commandlogs.length === 0 ? (
|
||||
|
||||
+60
-4
@@ -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>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { deleteShout } from '@/actions/admin-radio-moderation';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Shout = {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
message: string;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
}
|
||||
|
||||
export default async function AdminRadioModerationPage() {
|
||||
let shouts: Shout[] = [];
|
||||
try {
|
||||
shouts = await prisma.radioShouts.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
message: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
} catch {
|
||||
shouts = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/moderation">Moderation</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
|
||||
<Link href="/admin/radio/history">History</Link>
|
||||
</nav>
|
||||
|
||||
<h1>DJ Moderation</h1>
|
||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||
Review and remove radio shouts (50 most recent).
|
||||
</p>
|
||||
|
||||
<div className="admin-stats">
|
||||
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
||||
</div>
|
||||
|
||||
<section className="admin-section">
|
||||
<h2>Recent Shouts ({shouts.length})</h2>
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts to moderate.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Message</th>
|
||||
<th>Posted</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shouts.map((s) => (
|
||||
<tr key={String(s.id)}>
|
||||
<td>{String(s.id)}</td>
|
||||
<td>{String(s.userId)}</td>
|
||||
<td>{s.message}</td>
|
||||
<td className="muted">{formatDate(s.createdAt)}</td>
|
||||
<td>
|
||||
<form action={deleteShout}>
|
||||
<input type="hidden" name="id" value={String(s.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user