Extended the same design-system treatment to the public site, faithful to AtomCMS's "atom" theme. New src/components/public/ui.tsx provides the signature atom building blocks + a scoped CSS layer: - ContentCard: surface card with a primary-tinted header (icon circle + title + subtitle) and padded body — the atom content-card, used as every page's header and section wrapper. - StatBlock / stat-grid, EmptyState, OnlineBadge (online/offline pill), RankBadge (medals for the top 3), and responsive .card-grid helpers. Swept ~45 public pages (home/community/rankings hand-built as the reference; the rest via parallel agents that read the schema and preserved every query, server action, auth gate and field name): heroes → ContentCard headers, lists → card-grids in ContentCards, "no X" → EmptyState, status → OnlineBadge/RankBadge. The leaderboard gained Credits/Diamonds/Duckets tabs (usersCurrency). Behaviour unchanged. Verified on the prod server against amx_test: 15+ public pages render the content-cards/grids with real data, no errors; computed styles confirm the tinted header, icon circle, medal + online pills. Fixed an undefined --color-golden ref. tsc 0, vitest 49/49, next build 0.
109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
import Link from "next/link";
|
|
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
|
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 style={{ display: "grid", gap: "1.5rem" }}>
|
|
<ContentCard
|
|
icon="🏆"
|
|
title="Radio Listener Leaderboard"
|
|
subtitle="Top 50 listeners by points earned tuning in to the radio"
|
|
/>
|
|
|
|
<ContentCard padded={rows.length === 0}>
|
|
{rows.length === 0 ? (
|
|
<EmptyState icon="🏆">No listener points to show yet.</EmptyState>
|
|
) : (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "3.5rem" }}>Rank</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>
|
|
<RankBadge position={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>
|
|
)}
|
|
</ContentCard>
|
|
</main>
|
|
);
|
|
}
|