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
@@ -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