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.
225 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|