219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
"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} />
|
||
<span className="player-name">
|
||
<Username name={entry.display_name} appearance={entry} />
|
||
{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} />
|
||
<h2>
|
||
<Username name={entry.display_name} appearance={entry} />
|
||
</h2>
|
||
<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>
|
||
);
|
||
}
|