Web-tier features completing the AtomCMS→Next.js conversion (slice 2): UI/UX: - Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage, no-flash boot script) wired into the nav. - i18n (next-intl, cookie-based / no URL routing): en + it catalogs, request.ts, provider in root layout, LanguageSwitcher; shell (nav, header, footer) fully translated. URLs + access-guard unchanged. - globals.css: --muted/--border aliases used across admin pages. User features: - /messages: offline messages + friend-request accept (server action re-reads session, two directional rows, idempotent). - Email verification: signed-token /verify route + sendVerification wired into register (best-effort, never blocks signup). - Article reactions: toggle UI on news/[slug] + server action. - Content moderation service (website_wordfilter + optional OpenAI moderations, fail-open) wired into article comments + guestbook. Admin CRUD parity (Filament replacement): - /admin/shop (+ new/[id]) packages CRUD + read-only orders. - /admin/transactions read-only PayPal log. - /admin/permissions, /admin/tags, /admin/ads (+ new/[id]), /admin/help-questions (+ new/[id]), /admin/radio/history, /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity. Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new admin CRUD + /messages + /verify).
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
import Link from 'next/link';
|
|
import { prisma } from '@/lib/prisma';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
type History = {
|
|
id: bigint;
|
|
userId: bigint;
|
|
showName: string | null;
|
|
startedAt: Date;
|
|
endedAt: Date | null;
|
|
listenersCount: number;
|
|
notes: string | null;
|
|
};
|
|
|
|
function formatDate(d: Date | null): string {
|
|
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
|
}
|
|
|
|
/** Human-readable airtime between two timestamps (e.g. "1h 23m"). */
|
|
function formatDuration(start: Date, end: Date | null): string {
|
|
if (!end) return '—';
|
|
const ms = end.getTime() - start.getTime();
|
|
if (ms <= 0) return '—';
|
|
const mins = Math.round(ms / 60000);
|
|
const h = Math.floor(mins / 60);
|
|
const m = mins % 60;
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
export default async function AdminRadioHistoryPage() {
|
|
let history: History[] = [];
|
|
let names = new Map<string, string>();
|
|
let dbError = false;
|
|
|
|
try {
|
|
history = await prisma.radioHistory.findMany({
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
showName: true,
|
|
startedAt: true,
|
|
endedAt: true,
|
|
listenersCount: true,
|
|
notes: true,
|
|
},
|
|
orderBy: { startedAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
|
|
// Resolve DJ usernames (users.id is Int, radio_history.user_id is BigInt).
|
|
const ids = Array.from(new Set(history.map((h) => Number(h.userId)))).filter(
|
|
(n) => Number.isFinite(n) && n > 0,
|
|
);
|
|
if (ids.length > 0) {
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, username: true },
|
|
});
|
|
names = new Map(users.map((u) => [String(u.id), u.username]));
|
|
}
|
|
} catch {
|
|
dbError = true;
|
|
history = [];
|
|
}
|
|
|
|
return (
|
|
<main>
|
|
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
|
<Link href="/admin/radio">Radio</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>Radio Play History</h1>
|
|
<p className="muted">
|
|
Past DJ shows and airtime (<code>radio_history</code>), most recent first.
|
|
Read-only log of who was on air, when, and how many listeners tuned in.
|
|
</p>
|
|
|
|
{dbError ? (
|
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
Could not load radio history (database unavailable).
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{history.length === 0 ? (
|
|
<p className="muted">No radio history recorded.</p>
|
|
) : (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>DJ</th>
|
|
<th>Show</th>
|
|
<th>Started</th>
|
|
<th>Ended</th>
|
|
<th>Airtime</th>
|
|
<th>Listeners</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{history.map((h) => (
|
|
<tr key={String(h.id)}>
|
|
<td>{String(h.id)}</td>
|
|
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
|
|
<td>{h.showName ?? '—'}</td>
|
|
<td className="muted">{formatDate(h.startedAt)}</td>
|
|
<td className="muted">{formatDate(h.endedAt)}</td>
|
|
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
|
|
<td>{h.listenersCount}</td>
|
|
<td className="muted">{h.notes ?? '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|