Files
Epicnabbo-Catalogus-Updated…/src/app/(site)/leaderboard/page.tsx
T
openhands 7a41775c7e fix: disable route prefetching app-wide to avoid spurious requests
Wrap next/link in a shared Link component that ships prefetch=false by
default, so no route is ever prefetched (viewport or hover) anymore, and
drop the DNS prefetch hint. Removes hidden background requests that were
the source of intermittent issues.
2026-08-29 19:27:13 +02:00

225 lines
5.8 KiB
TypeScript

import { desc, eq } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import {
CurrencyIcon,
type CurrencyKind,
} from "@/components/shared/currency-icon";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
export const metadata = { title: "Leaderboard" };
// Currency type ids (see UsersCurrency in src/db/schema.ts):
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
const TABS = [
{ key: "credits", label: "Credits" },
{ key: "diamonds", label: "Diamonds" },
{ key: "duckets", label: "Duckets" },
{ key: "achievement", label: "Achievement" },
{ key: "respects", label: "Respects" },
{ key: "onlinetime", label: "Online Time" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const CURRENCY_TYPE: Record<"diamonds" | "duckets", number> = {
diamonds: 5,
duckets: 0,
};
function formatValue(key: TabKey, value: number): string {
if (key === "onlinetime") {
const hours = Math.floor(value / 3600);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
return `${hours}h`;
}
return value.toLocaleString();
}
type Row = { username: string; look: string; value: number };
async function loadCreditsRows(): Promise<Row[]> {
try {
const users = await cached("lb_credits", 60_000, () =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
})
.from(User)
.orderBy(desc(User.credits))
.limit(20),
);
return users.map((u) => ({
username: u.username,
look: u.look,
value: u.credits,
}));
} catch {
return [];
}
}
async function loadCurrencyRows(type: number): Promise<Row[]> {
try {
return await cached(`lb_currency_${type}`, 60_000, () =>
db
.select({
username: User.username,
look: User.look,
value: UsersCurrency.amount,
})
.from(UsersCurrency)
.innerJoin(User, eq(UsersCurrency.userId, User.id))
.where(eq(UsersCurrency.type, type))
.orderBy(desc(UsersCurrency.amount))
.limit(20),
);
} catch {
return [];
}
}
async function loadSettingsRows(
field: "achievementScore" | "respectsReceived" | "onlineTime",
): Promise<Row[]> {
try {
const column = UsersSettings[field];
return await cached(`lb_settings_${field}`, 60_000, () =>
db
.select({
username: User.username,
look: User.look,
value: column,
})
.from(UsersSettings)
.innerJoin(User, eq(UsersSettings.userId, User.id))
.orderBy(desc(column))
.limit(20),
);
} catch {
return [];
}
}
const SETTINGS_FIELD: Record<
"achievement" | "respects" | "onlinetime",
"achievementScore" | "respectsReceived" | "onlineTime"
> = {
achievement: "achievementScore",
respects: "respectsReceived",
onlinetime: "onlineTime",
};
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const t = await getTranslations("pages.leaderboard");
const { type } = await searchParams;
const active: TabKey = TABS.some((tab) => tab.key === type)
? (type as TabKey)
: "credits";
const rows = await (active === "credits"
? loadCreditsRows()
: active === "diamonds" || active === "duckets"
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
loadCurrencyRows(CURRENCY_TYPE[active])
: loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
));
const activeCurrency: CurrencyKind | null =
active === "credits" || active === "diamonds" || active === "duckets"
? active
: null;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📊"
title={t("title")}
subtitle={t("subtitle", { currency: t(`tab_${active}`).toLowerCase() })}
/>
<div className="nav" style={{ gap: "0.5rem", flexWrap: "wrap" }}>
{TABS.map((t2) => (
<Link
key={t2.key}
href={`/leaderboard?type=${t2.key}`}
className={`btn ${t2.key === active ? "btn-primary" : "btn-outline"}`}
>
{t(`tab_${t2.key}`)}
</Link>
))}
</div>
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
<EmptyState icon="📊">{t("emptyState")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3rem" }}>#</th>
<th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>{t("colPlayer")}</th>
<th style={{ textAlign: "right" }}>{t(`tab_${active}`)}</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
return (
<tr key={row.username}>
<td>
<RankBadge position={i + 1} />
</td>
<td>
<UserAvatarThumbnail
figure={row.look}
alt={`${row.username} avatar`}
/>
</td>
<td>
<Link
href={`/u/${encodeURIComponent(row.username)}`}
style={{ fontWeight: 700 }}
>
{row.username}
</Link>
</td>
<td style={{ textAlign: "right" }}>
{activeCurrency ? (
<span
className="currency"
style={{ display: "inline-flex" }}
>
<CurrencyIcon
kind={activeCurrency}
size={22}
alt=""
/>
{row.value.toLocaleString()}
</span>
) : (
<strong>{formatValue(active, row.value)}</strong>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}