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

+96
View File
@@ -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>
);
}