Files
healer-man/src/ui/DungeonLeaderboard.tsx
T
2026-09-13 11:07:56 -04:00

71 lines
4.6 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.
import { DUNGEON_LEADERBOARD_PAGE_SIZE, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { formatDungeonDuration, type DungeonClearFilter, type DungeonLeaderboard as LeaderboardData } from "../game/dungeonRunTypes";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter,
expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty,
selectedId, select }: {
readonly dungeonTitle: string;
readonly data: LeaderboardData;
readonly scope: LeaderboardScope;
readonly loading: boolean;
readonly filter: DungeonClearFilter;
readonly onFilter: (filter: DungeonClearFilter) => void;
readonly expanded: ReadonlySet<string>;
readonly onExpand: (id: string) => void;
readonly offset: number;
readonly onPage: (offset: number) => void;
readonly onRefresh: () => void;
readonly difficultyLabel: string;
readonly onDifficulty?: () => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
const controls = { selectedId, select };
return <section className="dungeon-leaderboard" aria-label={`${dungeonTitle} leaderboard`} aria-busy={loading}>
<header>
<p className="front-kicker">{scope === "world" ? "World leaderboard" : "This device · offline leaderboard"}</p>
<h3>Fastest expeditions</h3>
<div className="dungeon-leaderboard__filters" aria-label="Clear type">
<ControllerButton {...controls} controlId="leaderboard-final" aria-pressed={filter === "final"}
onClick={() => onFilter("final")}>Final Boss clears <small>All entries</small></ControllerButton>
<ControllerButton {...controls} controlId="leaderboard-full" aria-pressed={filter === "full"}
onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton>
</div>
<div className="dungeon-leaderboard__meta">
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
: <span>{difficultyLabel}</span>}
<span>{data.total} {data.total === 1 ? "run" : "runs"}</span>
<ControllerButton {...controls} controlId="leaderboard-refresh" onClick={onRefresh}>Refresh</ControllerButton>
</div>
</header>
<div className="dungeon-leaderboard__entries">
{loading ? <p className="dungeon-leaderboard__empty" role="status">Loading run times…</p>
: data.entries.length === 0 ? <p className="dungeon-leaderboard__empty">No {filter === "full" ? "full dungeon" : "final boss"} clears yet. Complete a run to set the first time.</p>
: <ol>
{data.entries.map((entry) => <li key={entry.id}>
<ControllerButton {...controls} controlId={`leaderboard-run-${entry.id}`}
className="dungeon-leaderboard__run" aria-expanded={expanded.has(entry.id)}
aria-controls={`run-details-${entry.id}`} onClick={() => onExpand(entry.id)}>
<b>#{entry.rank}</b><span><strong>{entry.party.find((member) => !member.npc)?.name ?? entry.party[0]?.name ?? "Party"}</strong>
<small>{entry.fullClear ? "All bosses" : `${entry.killedBossIds.length} bosses`} · {new Date(entry.finishedAt).toLocaleDateString()}</small></span>
<time>{formatDungeonDuration(entry.durationMs)}</time><em aria-hidden="true">{expanded.has(entry.id) ? "−" : "+"}</em>
</ControllerButton>
{expanded.has(entry.id) ? <div id={`run-details-${entry.id}`}><DungeonRunDetails dungeonId={entry.dungeonId}
difficultyId={entry.difficultyId} party={entry.party} killedBossIds={entry.killedBossIds} /></div> : null}
</li>)}
</ol>}
</div>
{data.total > DUNGEON_LEADERBOARD_PAGE_SIZE ? <nav className="dungeon-leaderboard__pages" aria-label="Leaderboard pages">
<ControllerButton {...controls} controlId="leaderboard-prev" disabled={offset === 0 || loading}
onClick={() => onPage(Math.max(0, offset - DUNGEON_LEADERBOARD_PAGE_SIZE))}>Previous</ControllerButton>
<span>{offset + 1}–{Math.min(offset + DUNGEON_LEADERBOARD_PAGE_SIZE, data.total)} of {data.total}</span>
<ControllerButton {...controls} controlId="leaderboard-next" disabled={offset + DUNGEON_LEADERBOARD_PAGE_SIZE >= data.total || loading}
onClick={() => onPage(offset + DUNGEON_LEADERBOARD_PAGE_SIZE)}>Next</ControllerButton>
</nav> : null}
</section>;
}