Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The radio_listener_points table in the live AtomCMS DB carries user_id +
|
||||
// points columns, but the generated Prisma model is a stub (id/timestamps only),
|
||||
// so it has no `points`/`userId` delegate fields. We therefore read it via
|
||||
// $queryRaw, joining users for the username/look — the same approach used for
|
||||
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
|
||||
// return BigInt columns as bigint, so values are coerced defensively.
|
||||
type LeaderRow = {
|
||||
username: string;
|
||||
look: string;
|
||||
points: number | bigint;
|
||||
};
|
||||
|
||||
async function loadRows(): Promise<{ username: string; look: string; points: number }[]> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<LeaderRow[]>`
|
||||
SELECT u.username AS username, u.look AS look, p.points AS points
|
||||
FROM radio_listener_points p
|
||||
INNER JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.points DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
username: r.username,
|
||||
look: r.look,
|
||||
points: Number(r.points),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RadioLeaderboardPage() {
|
||||
const [rows, imagerBase] = await Promise.all([
|
||||
loadRows(),
|
||||
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Listener Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 50 listeners by points earned tuning in to the radio.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No listener points to show yet.
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3rem" }}>#</th>
|
||||
<th style={{ width: "3.5rem" }}>Avatar</th>
|
||||
<th>Listener</th>
|
||||
<th style={{ textAlign: "right" }}>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${row.username} avatar`}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/u/${encodeURIComponent(row.username)}`}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{row.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 800 }}>
|
||||
{row.points.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user