Spaces:
Sleeping
Sleeping
| import { useEffect, useState } from "react"; | |
| import { useAdmin } from "../lib/admin"; | |
| import { fetchLeaderboard, type Leaderboard as LeaderboardData } from "../lib/api"; | |
| function formatElapsed(ms: number | null) { | |
| if (ms === null) { | |
| return "—"; | |
| } | |
| const totalSeconds = Math.floor(ms / 1000); | |
| const minutes = Math.floor(totalSeconds / 60) | |
| .toString() | |
| .padStart(2, "0"); | |
| const seconds = (totalSeconds % 60).toString().padStart(2, "0"); | |
| const centiseconds = Math.floor((ms % 1000) / 10) | |
| .toString() | |
| .padStart(2, "0"); | |
| return `${minutes}:${seconds}.${centiseconds}`; | |
| } | |
| type Props = { | |
| puzzleId: string; | |
| }; | |
| export function Leaderboard({ puzzleId }: Props) { | |
| const { enabled, token } = useAdmin(); | |
| const [data, setData] = useState<LeaderboardData | null>(null); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [includeTest, setIncludeTest] = useState(false); | |
| useEffect(() => { | |
| if (!enabled || !puzzleId) { | |
| setData(null); | |
| return; | |
| } | |
| let cancelled = false; | |
| setLoading(true); | |
| setError(null); | |
| fetchLeaderboard(puzzleId, { includeTest }) | |
| .then((result) => { | |
| if (!cancelled) { | |
| setData(result); | |
| } | |
| }) | |
| .catch((caught) => { | |
| if (!cancelled) { | |
| setError(caught instanceof Error ? caught.message : "Could not load leaderboard."); | |
| } | |
| }) | |
| .finally(() => { | |
| if (!cancelled) { | |
| setLoading(false); | |
| } | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [enabled, puzzleId, includeTest, token]); | |
| if (!enabled) { | |
| return null; | |
| } | |
| const entries = data?.entries ?? []; | |
| const solvedCount = entries.filter((entry) => entry.solved).length; | |
| return ( | |
| <section className="panel leaderboard-panel"> | |
| <div className="leaderboard-header"> | |
| <div> | |
| <div className="eyebrow">Leaderboard · admin</div> | |
| <div className="leaderboard-summary"> | |
| {loading | |
| ? "Loading…" | |
| : `${entries.length} session${entries.length === 1 ? "" : "s"} · ${solvedCount} solved`} | |
| </div> | |
| </div> | |
| <label className="leaderboard-toggle"> | |
| <input | |
| type="checkbox" | |
| checked={includeTest} | |
| onChange={(event) => setIncludeTest(event.target.checked)} | |
| /> | |
| include <code>test</code> | |
| </label> | |
| </div> | |
| {error ? <div className="status-box error">{error}</div> : null} | |
| {!loading && entries.length === 0 ? ( | |
| <div className="leaderboard-empty">No sessions for this puzzle yet.</div> | |
| ) : null} | |
| {entries.length > 0 ? ( | |
| <div className="leaderboard-table-wrap"> | |
| <table className="leaderboard-table"> | |
| <thead> | |
| <tr> | |
| <th>#</th> | |
| <th>Player</th> | |
| <th>Time</th> | |
| <th>Attempts</th> | |
| <th>Status</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {entries.map((entry, index) => ( | |
| <tr | |
| key={`${entry.player_name}-${entry.started_at}-${index}`} | |
| className={entry.solved ? "row-solved" : "row-unsolved"} | |
| > | |
| <td className="leaderboard-rank">{entry.solved ? index + 1 : "—"}</td> | |
| <td className="leaderboard-player">{entry.player_name}</td> | |
| <td className="leaderboard-time"> | |
| {entry.solved ? formatElapsed(entry.elapsed_ms) : "—"} | |
| </td> | |
| <td className="leaderboard-attempts">{entry.submission_count}</td> | |
| <td className="leaderboard-status"> | |
| {entry.solved ? "solved" : "attempted"} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ) : null} | |
| </section> | |
| ); | |
| } | |