Files
MineGeo/components/Leaderboard.tsx
pavel444-byte a7085e4e80
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Improve MineGeo UI and application flows
2026-10-18 11:44:04 +05:00

245 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import { Username } from "./Username";
import {
type LeaderboardData,
type LeaderboardEntry,
} from "@/shared/leaderboard";
export function LeaderboardEmpty({
unavailable = false,
search = false,
}: {
unavailable?: boolean;
search?: boolean;
}) {
const { t } = useI18n();
return (
<div
className="leaderboard-empty"
role={unavailable ? "status" : undefined}
>
<PixelTrophy />
<h2>
{unavailable
? t("The scoreboard is taking a break.")
: search
? t("No explorers found.")
: t("The first place is still open.")}
</h2>
<p>
{unavailable
? t("We couldn’t load the scores. Try again in a moment.")
: search
? t("Try another player name or clear your search.")
: t("Log in and finish five rounds to put your name on the board.")}
</p>
{!search && (
<Link
href={unavailable ? "/leaderboard" : "/servers"}
className="button primary"
>
{unavailable ? t("Reload scores") : t("Play an expedition")}{" "}
<ArrowRight size={16} />
</Link>
)}
</div>
);
}
export function LeaderboardTable({
entries,
compact = false,
playerId,
}: {
entries: LeaderboardEntry[];
compact?: boolean;
playerId?: string;
}) {
const { t, formatNumber } = useI18n();
return (
<div className={`leaderboard-table-wrap${compact ? " compact" : ""}`}>
<table className="leaderboard-table">
<caption className="sr-only">
{t(
"Players ranked by best five-round score. Ties are broken by average score, then games played.",
)}
</caption>
<thead>
<tr>
<th scope="col" className="rank-column">
{t("Rank")}
</th>
<th scope="col">{t("Explorer")}</th>
{!compact && (
<th scope="col" className="games-column">
{t("Games")}
</th>
)}
{!compact && (
<th scope="col" className="average-column">
{t("Average")}
</th>
)}
<th scope="col" className="score-column">
{t("Best score")}
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.player_id}
className={entry.player_id === playerId ? "your-row" : undefined}
>
<td className="rank-column">
<span
className={`rank-number${entry.rank <= 3 ? ` place-${entry.rank}` : ""}`}
>
{String(entry.rank).padStart(2, "0")}
</span>
</td>
<th scope="row">
<div className="leaderboard-player">
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
/>
<span className="player-name">
<Username name={entry.display_name} appearance={entry} />
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
title={t("Champion spotlight until {date}", {
date:
new Date(entry.tournament_champion.expires_at)
.toISOString()
.slice(0, 16)
.replace("T", " ") + " UTC",
})}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
</span>
</div>
</th>
{!compact && (
<td className="games-column">
{formatNumber(entry.games_played)}
</td>
)}
{!compact && (
<td className="average-column">
{formatNumber(entry.average_score)}
</td>
)}
<td className="score-column">
<strong>{formatNumber(entry.best_score)}</strong>
<span className="score-unit"> {t("pts")}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
const { t, formatNumber } = useI18n();
return (
<section className="winners-podium" aria-label={t("Top three explorers")}>
{leaders.map((entry, index) => (
<article
className={`podium-place podium-${index + 1}`}
key={entry.player_id}
>
<div className="podium-player">
<span className="podium-label">
{index === 0
? t("TOP EXPLORER")
: t("Place {place}", {
place: String(index + 1).padStart(2, "0"),
})}
</span>
<PlayerAvatar id={entry.player_id} avatarPath={entry.avatar_path} />
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>
</div>
<div className="podium-block" aria-hidden="true">
<span>0{index + 1}</span>
{index === 0 && <PixelTrophy />}
</div>
</article>
))}
</section>
);
}
export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
const { t, formatNumber } = useI18n();
return (
<section
className="section home-leaderboard"
aria-labelledby="home-leaderboard-heading"
>
<div className="leaderboard-preview-copy">
<div className="leaderboard-kicker">
<PixelTrophy />
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
</div>
<h2 id="home-leaderboard-heading">
{t("Good eye.")}
<br /> <span>{t("Great guess.")}</span>
</h2>
<p>
{t("Five rounds to find your way.")}
<br /> {t("One score to make your mark.")}
</p>
<Link className="text-link" href="/leaderboard">
{t("See the full leaderboard")} <ArrowRight size={16} />
</Link>
</div>
<div className="leaderboard-preview-board">
<div className="board-heading">
<span>{t("Top explorers")}</span>
<span>{t("All difficulties · All time")}</span>
</div>
{data?.entries.length ? (
<LeaderboardTable entries={data.entries} compact />
) : (
<LeaderboardEmpty unavailable={!data} />
)}
<div className="board-footnote">
{t("Best expedition score ·")}{" "}
{data
? t("Ranked explorers: {count}", {
count: formatNumber(data.total_players),
})
: t("Scores temporarily unavailable")}
</div>
</div>
</section>
);
}