HASH 8eeedb695220
DATE 2026-05-03
SUBJECT web: add challenge mode home page
FILES 14 CHANGED
HASH 8eeedb695220
DATE 2026-05-03
SUBJECT web: add challenge mode home page
FILES 14 CHANGED
┌─ web/src/components/Home/ChallengeRun/ChallengeRun.astro ──────────────────┐
│ diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.astro b/web/src/com │
│ ponents/Home/ChallengeRun/ChallengeRun.astro │
│ new file mode 100644 │
│ index 0000000..af2a617 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/ChallengeRun/ChallengeRun.astro │
│ @@ -0,0 +1,74 @@ │
│ +--- │
│ +import "./ChallengeRun.scss"; │
│ +import { formatDurationMMSS, formatShortDate } from "../../../lib/utils"; │
│ +import { getDungeonIconUrl } from "../../../lib/dungeonIcons"; │
│ +import TeamComposition from "../../Leaderboard/TeamComposition/TeamComposition.as │
│ tro"; │
│ +import type { TeamMember } from "../../../lib/types"; │
│ + │
│ +interface Props { │
│ + dungeonName: string; │
│ + dungeonSlug: string; │
│ + durationMs: number; │
│ + completedTimestamp: number; │
│ + teamMembers: TeamMember[]; │
│ + showDungeonIcon?: boolean; │
│ + showDungeonName?: boolean; │
│ + rank?: number; │
│ + bracket?: string; │
│ +} │
│ + │
│ +const { │
│ + dungeonName, │
│ + dungeonSlug, │
│ + durationMs, │
│ + completedTimestamp, │
│ + teamMembers, │
│ + showDungeonIcon = true, │
│ + showDungeonName = true, │
│ + rank, │
│ + bracket, │
│ +} = Astro.props; │
│ + │
│ +const dungeonIcon = getDungeonIconUrl(dungeonSlug, dungeonName); │
│ +const bracketClass = bracket ? `bracket-${bracket}` : ""; │
│ +--- │
│ + │
│ +<article class="challenge-run"> │
│ + { │
│ + showDungeonIcon && dungeonIcon && ( │
│ + <a │
│ + class="challenge-run__dungeon" │
│ + href={`/challenge-mode/season2/global/${dungeonSlug}`} │
│ + title={dungeonName} │
│ + > │
│ + <img src={dungeonIcon} alt={dungeonName} loading="lazy" /> │
│ + </a> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + rank && ( │
│ + <span class:list={["challenge-run__rank", "ranking-number", bracketClass]}> │
│ {rank}</span> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + showDungeonName && ( │
│ + <span class="challenge-run__dungeon-name">{dungeonName}</span> │
│ + ) │
│ + } │
│ + │
│ + <div class="challenge-run__team"> │
│ + <TeamComposition members={teamMembers} /> │
│ + </div> │
│ + │
│ + <span class="challenge-run__duration">{formatDurationMMSS(durationMs)}</span> │
│ + │
│ + <time │
│ + class="challenge-run__date" │
│ + datetime={new Date(completedTimestamp).toISOString()} │
│ + title={new Date(completedTimestamp).toLocaleString()} │
│ + > │
│ + {formatShortDate(completedTimestamp)} │
│ + </time> │
│ +</article> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...allengeRun/ChallengeRun.astro ───┐
│ diff --git a/web/src/components/Home/Challen │
│ geRun/ChallengeRun.astro b/web/src/component │
│ s/Home/ChallengeRun/ChallengeRun.astro │
│ new file mode 100644 │
│ index 0000000..af2a617 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.astro │
│ @@ -0,0 +1,74 @@ │
│ +--- │
│ +import "./ChallengeRun.scss"; │
│ +import { formatDurationMMSS, formatShortDat │
│ e } from "../../../lib/utils"; │
│ +import { getDungeonIconUrl } from "../../.. │
│ /lib/dungeonIcons"; │
│ +import TeamComposition from "../../Leaderbo │
│ ard/TeamComposition/TeamComposition.astro"; │
│ +import type { TeamMember } from "../../../l │
│ ib/types"; │
│ + │
│ +interface Props { │
│ + dungeonName: string; │
│ + dungeonSlug: string; │
│ + durationMs: number; │
│ + completedTimestamp: number; │
│ + teamMembers: TeamMember[]; │
│ + showDungeonIcon?: boolean; │
│ + showDungeonName?: boolean; │
│ + rank?: number; │
│ + bracket?: string; │
│ +} │
│ + │
│ +const { │
│ + dungeonName, │
│ + dungeonSlug, │
│ + durationMs, │
│ + completedTimestamp, │
│ + teamMembers, │
│ + showDungeonIcon = true, │
│ + showDungeonName = true, │
│ + rank, │
│ + bracket, │
│ +} = Astro.props; │
│ + │
│ +const dungeonIcon = getDungeonIconUrl(dunge │
│ onSlug, dungeonName); │
│ +const bracketClass = bracket ? `bracket-${b │
│ racket}` : ""; │
│ +--- │
│ + │
│ +<article class="challenge-run"> │
│ + { │
│ + showDungeonIcon && dungeonIcon && ( │
│ + <a │
│ + class="challenge-run__dungeon" │
│ + href={`/challenge-mode/season2/glob │
│ al/${dungeonSlug}`} │
│ + title={dungeonName} │
│ + > │
│ + <img src={dungeonIcon} alt={dungeon │
│ Name} loading="lazy" /> │
│ + </a> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + rank && ( │
│ + <span class:list={["challenge-run__ra │
│ nk", "ranking-number", bracketClass]}>{rank} │
│ </span> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + showDungeonName && ( │
│ + <span class="challenge-run__dungeon-n │
│ ame">{dungeonName}</span> │
│ + ) │
│ + } │
│ + │
│ + <div class="challenge-run__team"> │
│ + <TeamComposition members={teamMembers} │
│ /> │
│ + </div> │
│ + │
│ + <span class="challenge-run__duration">{fo │
│ rmatDurationMMSS(durationMs)}</span> │
│ + │
│ + <time │
│ + class="challenge-run__date" │
│ + datetime={new Date(completedTimestamp). │
│ toISOString()} │
│ + title={new Date(completedTimestamp).toL │
│ ocaleString()} │
│ + > │
│ + {formatShortDate(completedTimestamp)} │
│ + </time> │
│ +</article> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/ChallengeRun/ChallengeRun.scss ───────────────────┐
│ diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.scss b/web/src/comp │
│ onents/Home/ChallengeRun/ChallengeRun.scss │
│ new file mode 100644 │
│ index 0000000..0ad9de2 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/ChallengeRun/ChallengeRun.scss │
│ @@ -0,0 +1,94 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.challenge-run { │
│ + display: flex; │
│ + align-items: center; │
│ + gap: $spacing-sm; │
│ + padding: $spacing-xs $spacing-sm; │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-sm; │
│ + font-size: $font-size-sm; │
│ + │
│ + &__dungeon { │
│ + flex: 0 0 auto; │
│ + display: block; │
│ + width: 28px; │
│ + height: 28px; │
│ + border-radius: $radius-sm; │
│ + overflow: hidden; │
│ + border: 1px solid var(--border-color); │
│ + │
│ + img { │
│ + width: 100%; │
│ + height: 100%; │
│ + display: block; │
│ + } │
│ + │
│ + &:hover { │
│ + border-color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__rank { │
│ + flex: 0 0 auto; │
│ + font-weight: $font-weight-bold; │
│ + font-size: $font-size-base; │
│ + min-width: 2.2em; │
│ + text-align: center; │
│ + line-height: 1.4; │
│ + // colors come from .ranking-number .bracket-{name} (global ranking-utils con │
│ vention) │
│ + } │
│ + │
│ + &__dungeon-name { │
│ + flex: 0 0 auto; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + white-space: nowrap; │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + max-width: 18ch; │
│ + } │
│ + │
│ + &__team { │
│ + flex: 1 1 auto; │
│ + min-width: 0; │
│ + │
│ + // Tighten TeamComposition's default 12px gap for the dense home page rows. │
│ + // Scoped override; doesn't affect leaderboard pages where 12px is appropriat │
│ e. │
│ + .team-composition { │
│ + gap: 4px $spacing-sm; │
│ + │
│ + // TeamComposition's stock mobile rule (?720px) makes each .team-member │
│ + // full-width with a dark bubble background - designed for leaderboard │
│ + // table rows, not our flex-row ChallengeRun. The full-width bubbles │
│ + // collide with the rank/time/date positioned next to the team. Override │
│ + // back to inline rendering for the home page context. │
│ + @media (max-width: 720px) { │
│ + flex-direction: row; │
│ + │
│ + .team-member { │
│ + width: auto; │
│ + background: transparent; │
│ + padding: 0; │
│ + border-radius: 0; │
│ + } │
│ + } │
│ + } │
│ + } │
│ + │
│ + &__duration { │
│ + flex: 0 0 auto; │
│ + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + font-size: $font-size-base; │
│ + } │
│ + │
│ + &__date { │
│ + flex: 0 0 auto; │
│ + color: var(--text-tertiary); │
│ + font-size: $font-size-xs; │
│ + white-space: nowrap; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...hallengeRun/ChallengeRun.scss ───┐
│ diff --git a/web/src/components/Home/Challen │
│ geRun/ChallengeRun.scss b/web/src/components │
│ /Home/ChallengeRun/ChallengeRun.scss │
│ new file mode 100644 │
│ index 0000000..0ad9de2 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.scss │
│ @@ -0,0 +1,94 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.challenge-run { │
│ + display: flex; │
│ + align-items: center; │
│ + gap: $spacing-sm; │
│ + padding: $spacing-xs $spacing-sm; │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-sm; │
│ + font-size: $font-size-sm; │
│ + │
│ + &__dungeon { │
│ + flex: 0 0 auto; │
│ + display: block; │
│ + width: 28px; │
│ + height: 28px; │
│ + border-radius: $radius-sm; │
│ + overflow: hidden; │
│ + border: 1px solid var(--border-color); │
│ + │
│ + img { │
│ + width: 100%; │
│ + height: 100%; │
│ + display: block; │
│ + } │
│ + │
│ + &:hover { │
│ + border-color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__rank { │
│ + flex: 0 0 auto; │
│ + font-weight: $font-weight-bold; │
│ + font-size: $font-size-base; │
│ + min-width: 2.2em; │
│ + text-align: center; │
│ + line-height: 1.4; │
│ + // colors come from .ranking-number .br │
│ acket-{name} (global ranking-utils conventio │
│ n) │
│ + } │
│ + │
│ + &__dungeon-name { │
│ + flex: 0 0 auto; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + white-space: nowrap; │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + max-width: 18ch; │
│ + } │
│ + │
│ + &__team { │
│ + flex: 1 1 auto; │
│ + min-width: 0; │
│ + │
│ + // Tighten TeamComposition's default 12 │
│ px gap for the dense home page rows. │
│ + // Scoped override; doesn't affect lead │
│ erboard pages where 12px is appropriate. │
│ + .team-composition { │
│ + gap: 4px $spacing-sm; │
│ + │
│ + // TeamComposition's stock mobile rul │
│ e (?720px) makes each .team-member │
│ + // full-width with a dark bubble back │
│ ground - designed for leaderboard │
│ + // table rows, not our flex-row Chall │
│ engeRun. The full-width bubbles │
│ + // collide with the rank/time/date po │
│ sitioned next to the team. Override │
│ + // back to inline rendering for the h │
│ ome page context. │
│ + @media (max-width: 720px) { │
│ + flex-direction: row; │
│ + │
│ + .team-member { │
│ + width: auto; │
│ + background: transparent; │
│ + padding: 0; │
│ + border-radius: 0; │
│ + } │
│ + } │
│ + } │
│ + } │
│ + │
│ + &__duration { │
│ + flex: 0 0 auto; │
│ + font-family: ui-monospace, SFMono-Regul │
│ ar, Menlo, monospace; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + font-size: $font-size-base; │
│ + } │
│ + │
│ + &__date { │
│ + flex: 0 0 auto; │
│ + color: var(--text-tertiary); │
│ + font-size: $font-size-xs; │
│ + white-space: nowrap; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro ────────┐
│ diff --git a/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro b/w │
│ eb/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro │
│ new file mode 100644 │
│ index 0000000..eb3c166 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro │
│ @@ -0,0 +1,56 @@ │
│ +--- │
│ +import "./DungeonRecordCard.scss"; │
│ +import ChallengeRun from "../ChallengeRun/ChallengeRun.astro"; │
│ +import { getDungeonIconUrl } from "../../../lib/dungeonIcons"; │
│ +import type { HomeRunEntry } from "../../../lib/types"; │
│ + │
│ +interface Props { │
│ + dungeonName: string; │
│ + dungeonSlug: string; │
│ + runs: HomeRunEntry[]; │
│ + seasonId: number; │
│ +} │
│ + │
│ +const { dungeonName, dungeonSlug, runs, seasonId } = Astro.props; │
│ +const dungeonIcon = getDungeonIconUrl(dungeonSlug, dungeonName); │
│ +--- │
│ + │
│ +<section class="dungeon-record-card"> │
│ + <header class="dungeon-record-card__header"> │
│ + { │
│ + dungeonIcon && ( │
│ + <img │
│ + class="dungeon-record-card__icon" │
│ + src={dungeonIcon} │
│ + alt={dungeonName} │
│ + loading="lazy" │
│ + /> │
│ + ) │
│ + } │
│ + <h3 class="dungeon-record-card__title">{dungeonName}</h3> │
│ + <a │
│ + class="dungeon-record-card__view-all" │
│ + href={`/challenge-mode/season${seasonId}/global/${dungeonSlug}`} │
│ + > │
│ + View ? │
│ + </a> │
│ + </header> │
│ + │
│ + <div class="dungeon-record-card__runs"> │
│ + { │
│ + runs.map((r, i) => ( │
│ + <ChallengeRun │
│ + dungeonName={r.dungeon_name} │
│ + dungeonSlug={r.dungeon_slug} │
│ + durationMs={r.duration_ms} │
│ + completedTimestamp={r.completed_timestamp} │
│ + teamMembers={r.team_members} │
│ + showDungeonIcon={false} │
│ + showDungeonName={false} │
│ + rank={r.rank ?? i + 1} │
│ + bracket={r.bracket} │
│ + /> │
│ + )) │
│ + } │
│ + </div> │
│ +</section> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...dCard/DungeonRecordCard.astro ───┐
│ diff --git a/web/src/components/Home/Dungeon │
│ RecordCard/DungeonRecordCard.astro b/web/src │
│ /components/Home/DungeonRecordCard/DungeonRe │
│ cordCard.astro │
│ new file mode 100644 │
│ index 0000000..eb3c166 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/DungeonRecordC │
│ ard/DungeonRecordCard.astro │
│ @@ -0,0 +1,56 @@ │
│ +--- │
│ +import "./DungeonRecordCard.scss"; │
│ +import ChallengeRun from "../ChallengeRun/C │
│ hallengeRun.astro"; │
│ +import { getDungeonIconUrl } from "../../.. │
│ /lib/dungeonIcons"; │
│ +import type { HomeRunEntry } from "../../.. │
│ /lib/types"; │
│ + │
│ +interface Props { │
│ + dungeonName: string; │
│ + dungeonSlug: string; │
│ + runs: HomeRunEntry[]; │
│ + seasonId: number; │
│ +} │
│ + │
│ +const { dungeonName, dungeonSlug, runs, sea │
│ sonId } = Astro.props; │
│ +const dungeonIcon = getDungeonIconUrl(dunge │
│ onSlug, dungeonName); │
│ +--- │
│ + │
│ +<section class="dungeon-record-card"> │
│ + <header class="dungeon-record-card__heade │
│ r"> │
│ + { │
│ + dungeonIcon && ( │
│ + <img │
│ + class="dungeon-record-card__icon" │
│ + src={dungeonIcon} │
│ + alt={dungeonName} │
│ + loading="lazy" │
│ + /> │
│ + ) │
│ + } │
│ + <h3 class="dungeon-record-card__title"> │
│ {dungeonName}</h3> │
│ + <a │
│ + class="dungeon-record-card__view-all" │
│ + href={`/challenge-mode/season${season │
│ Id}/global/${dungeonSlug}`} │
│ + > │
│ + View ? │
│ + </a> │
│ + </header> │
│ + │
│ + <div class="dungeon-record-card__runs"> │
│ + { │
│ + runs.map((r, i) => ( │
│ + <ChallengeRun │
│ + dungeonName={r.dungeon_name} │
│ + dungeonSlug={r.dungeon_slug} │
│ + durationMs={r.duration_ms} │
│ + completedTimestamp={r.completed_t │
│ imestamp} │
│ + teamMembers={r.team_members} │
│ + showDungeonIcon={false} │
│ + showDungeonName={false} │
│ + rank={r.rank ?? i + 1} │
│ + bracket={r.bracket} │
│ + /> │
│ + )) │
│ + } │
│ + </div> │
│ +</section> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss ─────────┐
│ diff --git a/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss b/we │
│ b/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss │
│ new file mode 100644 │
│ index 0000000..b94d8cd │
│ --- /dev/null │
│ +++ b/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss │
│ @@ -0,0 +1,60 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +// No outer border / background - the page bg shows through. Matches the visual │
│ +// pattern used by TopPlayersList and RecentRuns (header + rows; no card chrome). │
│ +.dungeon-record-card { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-sm; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: center; │
│ + gap: $spacing-sm; │
│ + padding-bottom: $spacing-xs; │
│ + border-bottom: 1px solid var(--border-color); │
│ + } │
│ + │
│ + &__icon { │
│ + width: 28px; │
│ + height: 28px; │
│ + border-radius: $radius-sm; │
│ + border: 1px solid var(--border-color); │
│ + display: block; │
│ + } │
│ + │
│ + &__title { │
│ + margin: 0; │
│ + flex: 1; │
│ + font-size: $font-size-md; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + white-space: nowrap; │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + } │
│ + │
│ + &__view-all { │
│ + color: var(--text-tertiary); │
│ + text-decoration: none; │
│ + font-size: $font-size-xs; │
│ + transition: color 0.15s ease; │
│ + white-space: nowrap; │
│ + │
│ + &:hover { │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__runs { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: 4px; │
│ + │
│ + // Most rows fit on one line at 2-col grid widths; a few still wrap when name │
│ s │
│ + // are long. Sized to the natural wrap so 1-line rows pad up to match. │
│ + .challenge-run { │
│ + min-height: 54px; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rdCard/DungeonRecordCard.scss ───┐
│ diff --git a/web/src/components/Home/Dungeon │
│ RecordCard/DungeonRecordCard.scss b/web/src/ │
│ components/Home/DungeonRecordCard/DungeonRec │
│ ordCard.scss │
│ new file mode 100644 │
│ index 0000000..b94d8cd │
│ --- /dev/null │
│ +++ b/web/src/components/Home/DungeonRecordC │
│ ard/DungeonRecordCard.scss │
│ @@ -0,0 +1,60 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +// No outer border / background - the page │
│ bg shows through. Matches the visual │
│ +// pattern used by TopPlayersList and Recen │
│ tRuns (header + rows; no card chrome). │
│ +.dungeon-record-card { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-sm; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: center; │
│ + gap: $spacing-sm; │
│ + padding-bottom: $spacing-xs; │
│ + border-bottom: 1px solid var(--border-c │
│ olor); │
│ + } │
│ + │
│ + &__icon { │
│ + width: 28px; │
│ + height: 28px; │
│ + border-radius: $radius-sm; │
│ + border: 1px solid var(--border-color); │
│ + display: block; │
│ + } │
│ + │
│ + &__title { │
│ + margin: 0; │
│ + flex: 1; │
│ + font-size: $font-size-md; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + white-space: nowrap; │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + } │
│ + │
│ + &__view-all { │
│ + color: var(--text-tertiary); │
│ + text-decoration: none; │
│ + font-size: $font-size-xs; │
│ + transition: color 0.15s ease; │
│ + white-space: nowrap; │
│ + │
│ + &:hover { │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__runs { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: 4px; │
│ + │
│ + // Most rows fit on one line at 2-col g │
│ rid widths; a few still wrap when names │
│ + // are long. Sized to the natural wrap │
│ so 1-line rows pad up to match. │
│ + .challenge-run { │
│ + min-height: 54px; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/PlayerRankRow/PlayerRankRow.astro ────────────────┐
│ diff --git a/web/src/components/Home/PlayerRankRow/PlayerRankRow.astro b/web/src/c │
│ omponents/Home/PlayerRankRow/PlayerRankRow.astro │
│ new file mode 100644 │
│ index 0000000..eebd200 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/PlayerRankRow/PlayerRankRow.astro │
│ @@ -0,0 +1,81 @@ │
│ +--- │
│ +import "./PlayerRankRow.scss"; │
│ +import { formatDurationMMSS } from "../../../lib/utils"; │
│ +import { │
│ + getSpecInfo, │
│ + getSpecIcon, │
│ + getClassTextClass, │
│ +} from "../../../lib/wow-constants"; │
│ +import { buildPlayerProfileURL } from "../../Leaderboard/PlayerLink/PlayerLink.ts │
│ "; │
│ + │
│ +interface Props { │
│ + rank: number; │
│ + bracket?: string; │
│ + name: string; │
│ + realmSlug: string; │
│ + realmName?: string; │
│ + region: string; │
│ + className?: string; │
│ + activeSpecId?: number; │
│ + combinedBestTimeMs: number; │
│ + avatarUrl?: string; │
│ +} │
│ + │
│ +const { │
│ + rank, │
│ + bracket, │
│ + name, │
│ + realmSlug, │
│ + realmName, │
│ + region, │
│ + activeSpecId, │
│ + combinedBestTimeMs, │
│ + avatarUrl, │
│ +} = Astro.props; │
│ + │
│ +const bracketClass = bracket ? `bracket-${bracket}` : ""; │
│ +const profileUrl = buildPlayerProfileURL(region, realmSlug, name); │
│ +const specInfo = activeSpecId ? getSpecInfo(activeSpecId) : null; │
│ +const specIconUrl = specInfo ? getSpecIcon(specInfo.class, specInfo.spec) : null; │
│ +// Reuse the global .text-{class} utility (matches PlayerLink + WoWClassColors) │
│ +const classColorClass = specInfo ? getClassTextClass(specInfo.class) : ""; │
│ +--- │
│ + │
│ +<a class="player-rank-row" href={profileUrl}> │
│ + <span class:list={["player-rank-row__rank", "ranking-number", bracketClass]}>{r │
│ ank}</span> │
│ + │
│ + { │
│ + avatarUrl ? ( │
│ + <img │
│ + class="player-rank-row__avatar" │
│ + src={avatarUrl} │
│ + alt={`${name} avatar`} │
│ + loading="lazy" │
│ + /> │
│ + ) : ( │
│ + <div class="player-rank-row__avatar player-rank-row__avatar--placeholder" / │
│ > │
│ + ) │
│ + } │
│ + │
│ + <div class="player-rank-row__info"> │
│ + <span class="player-rank-row__name-line"> │
│ + { │
│ + specIconUrl && ( │
│ + <img │
│ + class="spec-icon" │
│ + src={specIconUrl} │
│ + alt={specInfo?.spec || ""} │
│ + loading="lazy" │
│ + /> │
│ + ) │
│ + } │
│ + <span class:list={["player-link-name", classColorClass]}>{name}</span> │
│ + </span> │
│ + <span class="player-rank-row__realm"> │
│ + {realmName || realmSlug} │
│ + <span class="player-rank-row__region">{region.toUpperCase()}</span> │
│ + </span> │
│ + </div> │
│ + │
│ + <span class="player-rank-row__time">{formatDurationMMSS(combinedBestTimeMs)}</s │
│ pan> │
│ +</a> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...erRankRow/PlayerRankRow.astro ───┐
│ diff --git a/web/src/components/Home/PlayerR │
│ ankRow/PlayerRankRow.astro b/web/src/compone │
│ nts/Home/PlayerRankRow/PlayerRankRow.astro │
│ new file mode 100644 │
│ index 0000000..eebd200 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/PlayerRankRow/ │
│ PlayerRankRow.astro │
│ @@ -0,0 +1,81 @@ │
│ +--- │
│ +import "./PlayerRankRow.scss"; │
│ +import { formatDurationMMSS } from "../../. │
│ ./lib/utils"; │
│ +import { │
│ + getSpecInfo, │
│ + getSpecIcon, │
│ + getClassTextClass, │
│ +} from "../../../lib/wow-constants"; │
│ +import { buildPlayerProfileURL } from "../. │
│ ./Leaderboard/PlayerLink/PlayerLink.ts"; │
│ + │
│ +interface Props { │
│ + rank: number; │
│ + bracket?: string; │
│ + name: string; │
│ + realmSlug: string; │
│ + realmName?: string; │
│ + region: string; │
│ + className?: string; │
│ + activeSpecId?: number; │
│ + combinedBestTimeMs: number; │
│ + avatarUrl?: string; │
│ +} │
│ + │
│ +const { │
│ + rank, │
│ + bracket, │
│ + name, │
│ + realmSlug, │
│ + realmName, │
│ + region, │
│ + activeSpecId, │
│ + combinedBestTimeMs, │
│ + avatarUrl, │
│ +} = Astro.props; │
│ + │
│ +const bracketClass = bracket ? `bracket-${b │
│ racket}` : ""; │
│ +const profileUrl = buildPlayerProfileURL(re │
│ gion, realmSlug, name); │
│ +const specInfo = activeSpecId ? getSpecInfo │
│ (activeSpecId) : null; │
│ +const specIconUrl = specInfo ? getSpecIcon( │
│ specInfo.class, specInfo.spec) : null; │
│ +// Reuse the global .text-{class} utility ( │
│ matches PlayerLink + WoWClassColors) │
│ +const classColorClass = specInfo ? getClass │
│ TextClass(specInfo.class) : ""; │
│ +--- │
│ + │
│ +<a class="player-rank-row" href={profileUrl │
│ }> │
│ + <span class:list={["player-rank-row__rank │
│ ", "ranking-number", bracketClass]}>{rank}</ │
│ span> │
│ + │
│ + { │
│ + avatarUrl ? ( │
│ + <img │
│ + class="player-rank-row__avatar" │
│ + src={avatarUrl} │
│ + alt={`${name} avatar`} │
│ + loading="lazy" │
│ + /> │
│ + ) : ( │
│ + <div class="player-rank-row__avatar p │
│ layer-rank-row__avatar--placeholder" /> │
│ + ) │
│ + } │
│ + │
│ + <div class="player-rank-row__info"> │
│ + <span class="player-rank-row__name-line │
│ "> │
│ + { │
│ + specIconUrl && ( │
│ + <img │
│ + class="spec-icon" │
│ + src={specIconUrl} │
│ + alt={specInfo?.spec || ""} │
│ + loading="lazy" │
│ + /> │
│ + ) │
│ + } │
│ + <span class:list={["player-link-name" │
│ , classColorClass]}>{name}</span> │
│ + </span> │
│ + <span class="player-rank-row__realm"> │
│ + {realmName || realmSlug} │
│ + <span class="player-rank-row__region" │
│ >{region.toUpperCase()}</span> │
│ + </span> │
│ + </div> │
│ + │
│ + <span class="player-rank-row__time">{form │
│ atDurationMMSS(combinedBestTimeMs)}</span> │
│ +</a> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/PlayerRankRow/PlayerRankRow.scss ─────────────────┐
│ diff --git a/web/src/components/Home/PlayerRankRow/PlayerRankRow.scss b/web/src/co │
│ mponents/Home/PlayerRankRow/PlayerRankRow.scss │
│ new file mode 100644 │
│ index 0000000..89258af │
│ --- /dev/null │
│ +++ b/web/src/components/Home/PlayerRankRow/PlayerRankRow.scss │
│ @@ -0,0 +1,83 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.player-rank-row { │
│ + display: grid; │
│ + grid-template-columns: 48px 40px 1fr auto; │
│ + align-items: center; │
│ + gap: $spacing-md; │
│ + padding: $spacing-sm $spacing-md; │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-md; │
│ + text-decoration: none; │
│ + color: inherit; │
│ + │
│ + &:hover { │
│ + border-color: var(--highlight-color); │
│ + text-decoration: none; │
│ + } │
│ + │
│ + &__rank { │
│ + text-align: center; │
│ + min-width: 2.2em; │
│ + // colors from .ranking-number + .bracket-{name} (global ranking-utils conven │
│ tion) │
│ + } │
│ + │
│ + &__avatar { │
│ + width: 40px; │
│ + height: 40px; │
│ + border-radius: 50%; │
│ + object-fit: cover; │
│ + border: 1px solid var(--border-color); │
│ + │
│ + &--placeholder { │
│ + background: var(--bg-primary); │
│ + } │
│ + } │
│ + │
│ + &__info { │
│ + display: flex; │
│ + flex-direction: column; │
│ + min-width: 0; │
│ + gap: 2px; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + &__name-line { │
│ + display: inline-flex; │
│ + align-items: center; │
│ + gap: $spacing-xs; │
│ + min-width: 0; │
│ + overflow: hidden; │
│ + │
│ + .player-link-name { │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + } │
│ + } │
│ + │
│ + &__realm { │
│ + font-size: $font-size-xs; │
│ + color: var(--text-tertiary); │
│ + display: inline-flex; │
│ + gap: $spacing-xs; │
│ + align-items: center; │
│ + } │
│ + │
│ + &__region { │
│ + background: var(--bg-primary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-sm; │
│ + padding: 0 4px; │
│ + font-size: 10px; │
│ + font-weight: $font-weight-bold; │
│ + color: var(--text-secondary); │
│ + } │
│ + │
│ + &__time { │
│ + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + font-size: $font-size-md; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...yerRankRow/PlayerRankRow.scss ───┐
│ diff --git a/web/src/components/Home/PlayerR │
│ ankRow/PlayerRankRow.scss b/web/src/componen │
│ ts/Home/PlayerRankRow/PlayerRankRow.scss │
│ new file mode 100644 │
│ index 0000000..89258af │
│ --- /dev/null │
│ +++ b/web/src/components/Home/PlayerRankRow/ │
│ PlayerRankRow.scss │
│ @@ -0,0 +1,83 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.player-rank-row { │
│ + display: grid; │
│ + grid-template-columns: 48px 40px 1fr auto │
│ ; │
│ + align-items: center; │
│ + gap: $spacing-md; │
│ + padding: $spacing-sm $spacing-md; │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-md; │
│ + text-decoration: none; │
│ + color: inherit; │
│ + │
│ + &:hover { │
│ + border-color: var(--highlight-color); │
│ + text-decoration: none; │
│ + } │
│ + │
│ + &__rank { │
│ + text-align: center; │
│ + min-width: 2.2em; │
│ + // colors from .ranking-number + .brack │
│ et-{name} (global ranking-utils convention) │
│ + } │
│ + │
│ + &__avatar { │
│ + width: 40px; │
│ + height: 40px; │
│ + border-radius: 50%; │
│ + object-fit: cover; │
│ + border: 1px solid var(--border-color); │
│ + │
│ + &--placeholder { │
│ + background: var(--bg-primary); │
│ + } │
│ + } │
│ + │
│ + &__info { │
│ + display: flex; │
│ + flex-direction: column; │
│ + min-width: 0; │
│ + gap: 2px; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + &__name-line { │
│ + display: inline-flex; │
│ + align-items: center; │
│ + gap: $spacing-xs; │
│ + min-width: 0; │
│ + overflow: hidden; │
│ + │
│ + .player-link-name { │
│ + overflow: hidden; │
│ + text-overflow: ellipsis; │
│ + } │
│ + } │
│ + │
│ + &__realm { │
│ + font-size: $font-size-xs; │
│ + color: var(--text-tertiary); │
│ + display: inline-flex; │
│ + gap: $spacing-xs; │
│ + align-items: center; │
│ + } │
│ + │
│ + &__region { │
│ + background: var(--bg-primary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-sm; │
│ + padding: 0 4px; │
│ + font-size: 10px; │
│ + font-weight: $font-weight-bold; │
│ + color: var(--text-secondary); │
│ + } │
│ + │
│ + &__time { │
│ + font-family: ui-monospace, SFMono-Regul │
│ ar, Menlo, monospace; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + font-size: $font-size-md; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/RecentRuns/RecentRuns.astro ──────────────────────┐
│ diff --git a/web/src/components/Home/RecentRuns/RecentRuns.astro b/web/src/compone │
│ nts/Home/RecentRuns/RecentRuns.astro │
│ new file mode 100644 │
│ index 0000000..2a55e88 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/RecentRuns/RecentRuns.astro │
│ @@ -0,0 +1,39 @@ │
│ +--- │
│ +import "./RecentRuns.scss"; │
│ +import ChallengeRun from "../ChallengeRun/ChallengeRun.astro"; │
│ +import type { HomeRunEntry } from "../../../lib/types"; │
│ + │
│ +interface Props { │
│ + runs: HomeRunEntry[]; │
│ +} │
│ + │
│ +const { runs } = Astro.props; │
│ +--- │
│ + │
│ +<section class="recent-runs"> │
│ + <header class="recent-runs__header"> │
│ + <h2>Recent Top Runs</h2> │
│ + </header> │
│ + │
│ + { │
│ + runs.length === 0 ? ( │
│ + <p class="recent-runs__empty">No recent top runs yet.</p> │
│ + ) : ( │
│ + <div class="recent-runs__list"> │
│ + {runs.map((r) => ( │
│ + <ChallengeRun │
│ + dungeonName={r.dungeon_name} │
│ + dungeonSlug={r.dungeon_slug} │
│ + durationMs={r.duration_ms} │
│ + completedTimestamp={r.completed_timestamp} │
│ + teamMembers={r.team_members} │
│ + showDungeonIcon={true} │
│ + showDungeonName={false} │
│ + rank={r.rankings?.global} │
│ + bracket={r.bracket} │
│ + /> │
│ + ))} │
│ + </div> │
│ + ) │
│ + } │
│ +</section> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...e/RecentRuns/RecentRuns.astro ───┐
│ diff --git a/web/src/components/Home/RecentR │
│ uns/RecentRuns.astro b/web/src/components/Ho │
│ me/RecentRuns/RecentRuns.astro │
│ new file mode 100644 │
│ index 0000000..2a55e88 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/RecentRuns/Rec │
│ entRuns.astro │
│ @@ -0,0 +1,39 @@ │
│ +--- │
│ +import "./RecentRuns.scss"; │
│ +import ChallengeRun from "../ChallengeRun/C │
│ hallengeRun.astro"; │
│ +import type { HomeRunEntry } from "../../.. │
│ /lib/types"; │
│ + │
│ +interface Props { │
│ + runs: HomeRunEntry[]; │
│ +} │
│ + │
│ +const { runs } = Astro.props; │
│ +--- │
│ + │
│ +<section class="recent-runs"> │
│ + <header class="recent-runs__header"> │
│ + <h2>Recent Top Runs</h2> │
│ + </header> │
│ + │
│ + { │
│ + runs.length === 0 ? ( │
│ + <p class="recent-runs__empty">No rece │
│ nt top runs yet.</p> │
│ + ) : ( │
│ + <div class="recent-runs__list"> │
│ + {runs.map((r) => ( │
│ + <ChallengeRun │
│ + dungeonName={r.dungeon_name} │
│ + dungeonSlug={r.dungeon_slug} │
│ + durationMs={r.duration_ms} │
│ + completedTimestamp={r.completed │
│ _timestamp} │
│ + teamMembers={r.team_members} │
│ + showDungeonIcon={true} │
│ + showDungeonName={false} │
│ + rank={r.rankings?.global} │
│ + bracket={r.bracket} │
│ + /> │
│ + ))} │
│ + </div> │
│ + ) │
│ + } │
│ +</section> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/RecentRuns/RecentRuns.scss ───────────────────────┐
│ diff --git a/web/src/components/Home/RecentRuns/RecentRuns.scss b/web/src/componen │
│ ts/Home/RecentRuns/RecentRuns.scss │
│ new file mode 100644 │
│ index 0000000..bbb9550 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/RecentRuns/RecentRuns.scss │
│ @@ -0,0 +1,39 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.recent-runs { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-lg; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: flex-end; │
│ + justify-content: space-between; │
│ + gap: $spacing-md; │
│ + │
│ + h2 { │
│ + margin: 0; │
│ + font-size: $font-size-2xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + &__subtitle { │
│ + margin: $spacing-xs 0 0; │
│ + color: var(--text-secondary); │
│ + font-size: $font-size-sm; │
│ + } │
│ + │
│ + &__empty { │
│ + color: var(--text-tertiary); │
│ + text-align: center; │
│ + padding: $spacing-2xl; │
│ + } │
│ + │
│ + &__list { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-sm; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...me/RecentRuns/RecentRuns.scss ───┐
│ diff --git a/web/src/components/Home/RecentR │
│ uns/RecentRuns.scss b/web/src/components/Hom │
│ e/RecentRuns/RecentRuns.scss │
│ new file mode 100644 │
│ index 0000000..bbb9550 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/RecentRuns/Rec │
│ entRuns.scss │
│ @@ -0,0 +1,39 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.recent-runs { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-lg; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: flex-end; │
│ + justify-content: space-between; │
│ + gap: $spacing-md; │
│ + │
│ + h2 { │
│ + margin: 0; │
│ + font-size: $font-size-2xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + &__subtitle { │
│ + margin: $spacing-xs 0 0; │
│ + color: var(--text-secondary); │
│ + font-size: $font-size-sm; │
│ + } │
│ + │
│ + &__empty { │
│ + color: var(--text-tertiary); │
│ + text-align: center; │
│ + padding: $spacing-2xl; │
│ + } │
│ + │
│ + &__list { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-sm; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/TopPlayersList/TopPlayersList.astro ──────────────┐
│ diff --git a/web/src/components/Home/TopPlayersList/TopPlayersList.astro b/web/src │
│ /components/Home/TopPlayersList/TopPlayersList.astro │
│ new file mode 100644 │
│ index 0000000..821f7a2 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/TopPlayersList/TopPlayersList.astro │
│ @@ -0,0 +1,55 @@ │
│ +--- │
│ +import "./TopPlayersList.scss"; │
│ +import PlayerRankRow from "../PlayerRankRow/PlayerRankRow.astro"; │
│ +import type { HomePlayerEntry } from "../../../lib/types"; │
│ + │
│ +interface Props { │
│ + title: string; │
│ + players: HomePlayerEntry[]; │
│ + scope: "global" | "regional"; │
│ + viewAllHref?: string; │
│ +} │
│ + │
│ +const { title, players, scope, viewAllHref } = Astro.props; │
│ + │
│ +// Pick the bracket that matches the scope of this list. │
│ +// Global list shows global rank; regional list shows regional rank - color the b │
│ adge accordingly. │
│ +function bracketFor(p: HomePlayerEntry): string | undefined { │
│ + return scope === "global" ? p.global_ranking_bracket : p.regional_ranking_brack │
│ et; │
│ +} │
│ +--- │
│ + │
│ +<section class="top-players-list"> │
│ + <header class="top-players-list__header"> │
│ + <h3>{title}</h3> │
│ + { │
│ + viewAllHref && ( │
│ + <a class="top-players-list__view-all" href={viewAllHref}> │
│ + View all ? │
│ + </a> │
│ + ) │
│ + } │
│ + </header> │
│ + │
│ + { │
│ + players.length === 0 ? ( │
│ + <p class="top-players-list__empty">No ranked players in this scope yet.</p> │
│ + ) : ( │
│ + <div class="top-players-list__rows"> │
│ + {players.map((p) => ( │
│ + <PlayerRankRow │
│ + rank={p.rank} │
│ + bracket={bracketFor(p)} │
│ + name={p.name} │
│ + realmSlug={p.realm_slug} │
│ + realmName={p.realm_name} │
│ + region={p.region} │
│ + activeSpecId={p.active_spec_id} │
│ + combinedBestTimeMs={p.combined_best_time_ms} │
│ + avatarUrl={p.avatar_url} │
│ + /> │
│ + ))} │
│ + </div> │
│ + ) │
│ + } │
│ +</section> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...yersList/TopPlayersList.astro ───┐
│ diff --git a/web/src/components/Home/TopPlay │
│ ersList/TopPlayersList.astro b/web/src/compo │
│ nents/Home/TopPlayersList/TopPlayersList.ast │
│ ro │
│ new file mode 100644 │
│ index 0000000..821f7a2 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/TopPlayersList │
│ /TopPlayersList.astro │
│ @@ -0,0 +1,55 @@ │
│ +--- │
│ +import "./TopPlayersList.scss"; │
│ +import PlayerRankRow from "../PlayerRankRow │
│ /PlayerRankRow.astro"; │
│ +import type { HomePlayerEntry } from "../.. │
│ /../lib/types"; │
│ + │
│ +interface Props { │
│ + title: string; │
│ + players: HomePlayerEntry[]; │
│ + scope: "global" | "regional"; │
│ + viewAllHref?: string; │
│ +} │
│ + │
│ +const { title, players, scope, viewAllHref │
│ } = Astro.props; │
│ + │
│ +// Pick the bracket that matches the scope │
│ of this list. │
│ +// Global list shows global rank; regional │
│ list shows regional rank - color the badge a │
│ ccordingly. │
│ +function bracketFor(p: HomePlayerEntry): st │
│ ring | undefined { │
│ + return scope === "global" ? p.global_rank │
│ ing_bracket : p.regional_ranking_bracket; │
│ +} │
│ +--- │
│ + │
│ +<section class="top-players-list"> │
│ + <header class="top-players-list__header"> │
│ + <h3>{title}</h3> │
│ + { │
│ + viewAllHref && ( │
│ + <a class="top-players-list__view-al │
│ l" href={viewAllHref}> │
│ + View all ? │
│ + </a> │
│ + ) │
│ + } │
│ + </header> │
│ + │
│ + { │
│ + players.length === 0 ? ( │
│ + <p class="top-players-list__empty">No │
│ ranked players in this scope yet.</p> │
│ + ) : ( │
│ + <div class="top-players-list__rows"> │
│ + {players.map((p) => ( │
│ + <PlayerRankRow │
│ + rank={p.rank} │
│ + bracket={bracketFor(p)} │
│ + name={p.name} │
│ + realmSlug={p.realm_slug} │
│ + realmName={p.realm_name} │
│ + region={p.region} │
│ + activeSpecId={p.active_spec_id} │
│ + combinedBestTimeMs={p.combined_ │
│ best_time_ms} │
│ + avatarUrl={p.avatar_url} │
│ + /> │
│ + ))} │
│ + </div> │
│ + ) │
│ + } │
│ +</section> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/TopPlayersList/TopPlayersList.scss ───────────────┐
│ diff --git a/web/src/components/Home/TopPlayersList/TopPlayersList.scss b/web/src/ │
│ components/Home/TopPlayersList/TopPlayersList.scss │
│ new file mode 100644 │
│ index 0000000..af3f48e │
│ --- /dev/null │
│ +++ b/web/src/components/Home/TopPlayersList/TopPlayersList.scss │
│ @@ -0,0 +1,44 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.top-players-list { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-md; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: baseline; │
│ + justify-content: space-between; │
│ + gap: $spacing-md; │
│ + │
│ + h3 { │
│ + margin: 0; │
│ + font-size: $font-size-xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + &__view-all { │
│ + color: var(--text-tertiary); │
│ + text-decoration: none; │
│ + font-size: $font-size-sm; │
│ + transition: color 0.15s ease; │
│ + │
│ + &:hover { │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__empty { │
│ + color: var(--text-tertiary); │
│ + text-align: center; │
│ + padding: $spacing-lg; │
│ + } │
│ + │
│ + &__rows { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-xs; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ayersList/TopPlayersList.scss ───┐
│ diff --git a/web/src/components/Home/TopPlay │
│ ersList/TopPlayersList.scss b/web/src/compon │
│ ents/Home/TopPlayersList/TopPlayersList.scss │
│ new file mode 100644 │
│ index 0000000..af3f48e │
│ --- /dev/null │
│ +++ b/web/src/components/Home/TopPlayersList │
│ /TopPlayersList.scss │
│ @@ -0,0 +1,44 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.top-players-list { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-md; │
│ + │
│ + &__header { │
│ + display: flex; │
│ + align-items: baseline; │
│ + justify-content: space-between; │
│ + gap: $spacing-md; │
│ + │
│ + h3 { │
│ + margin: 0; │
│ + font-size: $font-size-xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + &__view-all { │
│ + color: var(--text-tertiary); │
│ + text-decoration: none; │
│ + font-size: $font-size-sm; │
│ + transition: color 0.15s ease; │
│ + │
│ + &:hover { │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + &__empty { │
│ + color: var(--text-tertiary); │
│ + text-align: center; │
│ + padding: $spacing-lg; │
│ + } │
│ + │
│ + &__rows { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-xs; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ .../components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro ───┐
│ diff --git a/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav. │
│ astro b/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro │
│ index 52e29a3..3a7a4e3 100644 │
│ --- a/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro │
│ +++ b/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro │
│ @@ -3,7 +3,7 @@ import "./LeaderboardTypeNav.scss"; │
│ │
│ interface Props { │
│ currentType?: "dungeon" | "player"; │
│ - currentTab?: "dungeon" | "player" | "status"; │
│ + currentTab?: "home" | "dungeon" | "player" | "status"; │
│ currentRegion?: string; │
│ currentRealm?: string; │
│ currentSeason?: number; │
│ @@ -47,6 +47,12 @@ if (currentRegion === "global") { │
│ --- │
│ │
│ <nav class="leaderboard-type-nav"> │
│ + <a │
│ + href="/" │
│ + class:list={["nav-tab", { active: currentTab === "home" }]} │
│ + > │
│ + Home │
│ + </a> │
│ <a │
│ href={dungeonUrl} │
│ class:list={["nav-tab", { active: currentTab === "dungeon" }]} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...eNav/LeaderboardTypeNav.astro ───┐
│ diff --git a/web/src/components/Leaderboard/ │
│ LeaderboardTypeNav/LeaderboardTypeNav.astro │
│ b/web/src/components/Leaderboard/Leaderboard │
│ TypeNav/LeaderboardTypeNav.astro │
│ index 52e29a3..3a7a4e3 100644 │
│ --- a/web/src/components/Leaderboard/Leaderb │
│ oardTypeNav/LeaderboardTypeNav.astro │
│ +++ b/web/src/components/Leaderboard/Leaderb │
│ oardTypeNav/LeaderboardTypeNav.astro │
│ @@ -3,7 +3,7 @@ import "./LeaderboardTypeNav │
│ .scss"; │
│ │
│ interface Props { │
│ currentType?: "dungeon" | "player"; │
│ - currentTab?: "dungeon" | "player" | "stat │
│ us"; │
│ + currentTab?: "home" | "dungeon" | "player │
│ " | "status"; │
│ currentRegion?: string; │
│ currentRealm?: string; │
│ currentSeason?: number; │
│ @@ -47,6 +47,12 @@ if (currentRegion === "gl │
│ obal") { │
│ --- │
│ │
│ <nav class="leaderboard-type-nav"> │
│ + <a │
│ + href="/" │
│ + class:list={["nav-tab", { active: curre │
│ ntTab === "home" }]} │
│ + > │
│ + Home │
│ + </a> │
│ <a │
│ href={dungeonUrl} │
│ class:list={["nav-tab", { active: curre │
│ ntTab === "dungeon" }]} │
└──────────────────────────────────────────────┘
┌─ web/src/lib/types.ts ─────────────────────────────────────────────────────┐
│ diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts │
│ index 7dabde9..f05aaf9 100644 │
│ --- a/web/src/lib/types.ts │
│ +++ b/web/src/lib/types.ts │
│ @@ -172,6 +172,42 @@ export interface APIResponse<T> { │
│ error?: string; │
│ } │
│ │
│ +// Home page types - mirror nix/pkgs/ookstats/src/internal/generator/home.go │
│ +// (HomeRunEntry / HomePlayerEntry). Keep field names in sync with that file. │
│ + │
│ +export interface HomeRunEntry { │
│ + rank?: number; │
│ + bracket?: string; │
│ + run_id: number; │
│ + dungeon_id: number; │
│ + dungeon_name: string; │
│ + dungeon_slug: string; │
│ + duration_ms: number; │
│ + completed_timestamp: number; │
│ + // Only set on entries in `recent_top_runs` (cross-season feed): │
│ + season_id?: number; │
│ + rankings?: { global?: number }; │
│ + team_members: TeamMember[]; │
│ +} │
│ + │
│ +export interface HomePlayerEntry { │
│ + rank: number; │
│ + player_id: number; │
│ + name: string; │
│ + realm_slug: string; │
│ + realm_name?: string; │
│ + region: string; │
│ + class_name?: string; │
│ + active_spec_id?: number; │
│ + active_spec_name?: string; │
│ + combined_best_time_ms: number; │
│ + global_ranking?: number; │
│ + global_ranking_bracket?: string; │
│ + regional_ranking?: number; │
│ + regional_ranking_bracket?: string; │
│ + avatar_url?: string; │
│ +} │
│ + │
│ // frontend component props │
│ export interface LeaderboardTableProps { │
│ initialData?: LeaderboardData; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/lib/types.ts ───────────────┐
│ diff --git a/web/src/lib/types.ts b/web/src/ │
│ lib/types.ts │
│ index 7dabde9..f05aaf9 100644 │
│ --- a/web/src/lib/types.ts │
│ +++ b/web/src/lib/types.ts │
│ @@ -172,6 +172,42 @@ export interface APIRes │
│ ponse<T> { │
│ error?: string; │
│ } │
│ │
│ +// Home page types - mirror nix/pkgs/ooksta │
│ ts/src/internal/generator/home.go │
│ +// (HomeRunEntry / HomePlayerEntry). Keep f │
│ ield names in sync with that file. │
│ + │
│ +export interface HomeRunEntry { │
│ + rank?: number; │
│ + bracket?: string; │
│ + run_id: number; │
│ + dungeon_id: number; │
│ + dungeon_name: string; │
│ + dungeon_slug: string; │
│ + duration_ms: number; │
│ + completed_timestamp: number; │
│ + // Only set on entries in `recent_top_run │
│ s` (cross-season feed): │
│ + season_id?: number; │
│ + rankings?: { global?: number }; │
│ + team_members: TeamMember[]; │
│ +} │
│ + │
│ +export interface HomePlayerEntry { │
│ + rank: number; │
│ + player_id: number; │
│ + name: string; │
│ + realm_slug: string; │
│ + realm_name?: string; │
│ + region: string; │
│ + class_name?: string; │
│ + active_spec_id?: number; │
│ + active_spec_name?: string; │
│ + combined_best_time_ms: number; │
│ + global_ranking?: number; │
│ + global_ranking_bracket?: string; │
│ + regional_ranking?: number; │
│ + regional_ranking_bracket?: string; │
│ + avatar_url?: string; │
│ +} │
│ + │
│ // frontend component props │
│ export interface LeaderboardTableProps { │
│ initialData?: LeaderboardData; │
└──────────────────────────────────────────────┘
┌─ web/src/lib/utils.ts ─────────────────────────────────────────────────────┐
│ diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts │
│ index 4929860..6899b70 100644 │
│ --- a/web/src/lib/utils.ts │
│ +++ b/web/src/lib/utils.ts │
│ @@ -26,6 +26,14 @@ export function formatTimestamp(timestamp: number): string { │
│ }); │
│ } │
│ │
│ +// Compact "Mar 27" - for dense layouts where formatTimestamp's time-of-day eats │
│ space. │
│ +export function formatShortDate(timestamp: number): string { │
│ + return new Date(timestamp).toLocaleDateString("en-US", { │
│ + month: "short", │
│ + day: "numeric", │
│ + }); │
│ +} │
│ + │
│ export function formatSimulationDate(timestamp: string | number): string { │
│ return new Date(timestamp).toLocaleString("en-US", { │
│ year: "numeric", │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/lib/utils.ts ───────────────┐
│ diff --git a/web/src/lib/utils.ts b/web/src/ │
│ lib/utils.ts │
│ index 4929860..6899b70 100644 │
│ --- a/web/src/lib/utils.ts │
│ +++ b/web/src/lib/utils.ts │
│ @@ -26,6 +26,14 @@ export function formatTim │
│ estamp(timestamp: number): string { │
│ }); │
│ } │
│ │
│ +// Compact "Mar 27" - for dense layouts whe │
│ re formatTimestamp's time-of-day eats space. │
│ +export function formatShortDate(timestamp: │
│ number): string { │
│ + return new Date(timestamp).toLocaleDateSt │
│ ring("en-US", { │
│ + month: "short", │
│ + day: "numeric", │
│ + }); │
│ +} │
│ + │
│ export function formatSimulationDate(timest │
│ amp: string | number): string { │
│ return new Date(timestamp).toLocaleString │
│ ("en-US", { │
│ year: "numeric", │
└──────────────────────────────────────────────┘
┌─ web/src/pages/index.astro ────────────────────────────────────────────────┐
│ diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro │
│ index 5041278..7c4d694 100644 │
│ --- a/web/src/pages/index.astro │
│ +++ b/web/src/pages/index.astro │
│ @@ -1,15 +1,266 @@ │
│ --- │
│ -import SimulationLayout from "../layouts/SimulationLayout.astro"; │
│ +import PageLayout from "../layouts/PageLayout.astro"; │
│ +import PlayerSearch from "../components/PlayerSearch/PlayerSearch.astro"; │
│ +import LeaderboardTypeNav from "../components/Leaderboard/LeaderboardTypeNav/Lead │
│ erboardTypeNav.astro"; │
│ +import RecentRuns from "../components/Home/RecentRuns/RecentRuns.astro"; │
│ +import DungeonRecordCard from "../components/Home/DungeonRecordCard/DungeonRecord │
│ Card.astro"; │
│ +import TopPlayersList from "../components/Home/TopPlayersList/TopPlayersList.astr │
│ o"; │
│ + │
│ +export const prerender = false; │
│ + │
│ +let home: any = null; │
│ +try { │
│ + const response = await fetch(`${Astro.url.origin}/api/home.json`); │
│ + if (response.ok) { │
│ + home = await response.json(); │
│ + } │
│ +} catch (error) { │
│ + console.error("[Home] Failed to load home.json:", error); │
│ +} │
│ + │
│ +// Pick the latest season (highest key) as the focus │
│ +const seasonKeys = home ? Object.keys(home.seasons).sort((a, b) => parseInt(b) - │
│ parseInt(a)) : []; │
│ +const currentSeasonKey = seasonKeys[0]; │
│ +const currentSeason = currentSeasonKey ? home.seasons[currentSeasonKey] : null; │
│ + │
│ +const recentRuns = home?.recent_top_runs ?? []; │
│ + │
│ +// Sort dungeons by ID for consistent layout │
│ +const dungeonEntries = currentSeason │
│ + ? Object.entries(currentSeason.top_runs_per_dungeon as Record<string, any[]>) │
│ + .filter(([_, runs]) => runs && runs.length > 0) │
│ + .sort(([_, a], [__, b]) => (a[0]?.dungeon_id ?? 0) - (b[0]?.dungeon_id ?? 0 │
│ )) │
│ + : []; │
│ + │
│ +const generatedAt = home?.generated_at ? new Date(home.generated_at) : null; │
│ --- │
│ │
│ -<SimulationLayout title="Simulations" description="Redirecting..."> │
│ - <div class="card"> │
│ - <p>Redirecting to benchmarks...</p> │
│ +<PageLayout │
│ + title="WoW Mists of Pandaria Challenge Mode Leaderboards" │
│ + description="Top challenge mode times, recent record-setting runs, and the worl │
│ d's best players for World of Warcraft: Mists of Pandaria Classic." │
│ +> │
│ + <div class="home"> │
│ + { │
│ + !home && ( │
│ + <div class="home__error"> │
│ + <h2>Couldn't load home data</h2> │
│ + <p>The home.json file is missing or unreachable. Try regenerating it vi │
│ a <code>ookstats generate home</code>.</p> │
│ + </div> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + home && ( │
│ + <> │
│ + <header class="home__hero"> │
│ + <h1>Challenge Mode Leaderboards</h1> │
│ + {generatedAt && ( │
│ + <span class="home__updated"> │
│ + Data updated {generatedAt.toLocaleString("en-US", { month: "short │
│ ", day: "numeric", hour: "2-digit", minute: "2-digit" })} │
│ + </span> │
│ + )} │
│ + </header> │
│ + │
│ + <PlayerSearch /> │
│ + │
│ + <LeaderboardTypeNav │
│ + currentTab="home" │
│ + currentSeason={parseInt(currentSeasonKey)} │
│ + /> │
│ + │
│ + {currentSeason && ( │
│ + <section class="home__section"> │
│ + <header class="home__section-header"> │
│ + <h2>Top Players - {currentSeason.season_name || `Season ${current │
│ SeasonKey}`}</h2> │
│ + </header> │
│ + <div class="home__players-grid"> │
│ + <TopPlayersList │
│ + title="Global" │
│ + scope="global" │
│ + players={currentSeason.top_players.global} │
│ + viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /global`} │
│ + /> │
│ + <TopPlayersList │
│ + title="US" │
│ + scope="regional" │
│ + players={currentSeason.top_players.us} │
│ + viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /us`} │
│ + /> │
│ + <TopPlayersList │
│ + title="EU" │
│ + scope="regional" │
│ + players={currentSeason.top_players.eu} │
│ + viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /eu`} │
│ + /> │
│ + <TopPlayersList │
│ + title="KR" │
│ + scope="regional" │
│ + players={currentSeason.top_players.kr} │
│ + viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /kr`} │
│ + /> │
│ + <TopPlayersList │
│ + title="TW" │
│ + scope="regional" │
│ + players={currentSeason.top_players.tw} │
│ + viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /tw`} │
│ + /> │
│ + </div> │
│ + </section> │
│ + )} │
│ + │
│ + {currentSeason && ( │
│ + <section class="home__section"> │
│ + <header class="home__section-header"> │
│ + <h2>Dungeon Records - {currentSeason.season_name || `Season ${cur │
│ rentSeasonKey}`}</h2> │
│ + </header> │
│ + <div class="home__dungeon-grid"> │
│ + {dungeonEntries.map(([slug, runs]) => ( │
│ + <DungeonRecordCard │
│ + dungeonName={runs[0].dungeon_name} │
│ + dungeonSlug={slug} │
│ + runs={runs} │
│ + seasonId={parseInt(currentSeasonKey)} │
│ + /> │
│ + ))} │
│ + </div> │
│ + </section> │
│ + )} │
│ + │
│ + <section class="home__section"> │
│ + <RecentRuns runs={recentRuns} /> │
│ + </section> │
│ + </> │
│ + ) │
│ + } │
│ </div> │
│ - <script is:inline> │
│ - (function () { │
│ - var url = new URL(window.location.href); │
│ - window.location.replace("/simulation/benchmark" + url.search); │
│ - })(); │
│ - </script> │
│ -</SimulationLayout> │
│ +</PageLayout> │
│ + │
│ +<style lang="scss"> │
│ + @use "../styles/core/tokens" as *; │
│ + │
│ + // Match CM page wrapper (.leaderboard-page on /challenge-mode/* pages) │
│ + // so navigating between sections feels cohesive. No flex-gap on the wrapper - │
│ + // each child component (LeaderboardHeader-style hero, PlayerSearch, nav) carri │
│ es │
│ + // its own bottom margin to match the CM page rhythm. │
│ + .home { │
│ + max-width: 1200px; │
│ + margin: 0 auto; │
│ + padding: 20px; │
│ + } │
│ + │
│ + .home__error { │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-lg; │
│ + padding: $spacing-2xl; │
│ + text-align: center; │
│ + color: var(--text-secondary); │
│ + code { │
│ + background: var(--bg-primary); │
│ + padding: 2px 6px; │
│ + border-radius: $radius-sm; │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + // Mirror LeaderboardHeader's spacing/typography exactly so home title flows │
│ + // visually like every other CM-section page header. │
│ + .home__hero { │
│ + display: flex; │
│ + align-items: baseline; │
│ + justify-content: space-between; │
│ + gap: $spacing-lg; │
│ + flex-wrap: wrap; │
│ + margin: 8px 0 16px 0; │
│ + │
│ + h1 { │
│ + margin: 0; │
│ + font-size: 2em; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-bold; │
│ + } │
│ + } │
│ + │
│ + .home__updated { │
│ + color: var(--text-tertiary); │
│ + font-size: $font-size-sm; │
│ + } │
│ + │
│ + .home__section { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-lg; │
│ + │
│ + & + & { │
│ + margin-top: $spacing-2xl; │
│ + } │
│ + } │
│ + │
│ + .home__section-header { │
│ + h2 { │
│ + margin: 0; │
│ + font-size: $font-size-2xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + // Flexbox (not grid) so an incomplete last row centers horizontally. │
│ + // The `:global()` is needed because Astro scopes `> *` to local elements, │
│ + // but the children are component roots from DungeonRecordCard / TopPlayersList │
│ . │
│ + .home__dungeon-grid { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + justify-content: center; │
│ + gap: $spacing-md $spacing-lg; │
│ + } │
│ + │
│ + .home__dungeon-grid > :global(.dungeon-record-card) { │
│ + flex: 1 1 calc(50% - #{$spacing-lg * 0.5}); │
│ + max-width: calc(50% - #{$spacing-lg * 0.5}); │
│ + min-width: 0; │
│ + } │
│ + │
│ + .home__players-grid { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + justify-content: center; │
│ + gap: $spacing-lg; │
│ + } │
│ + │
│ + .home__players-grid > :global(.top-players-list) { │
│ + // 1px fudge to avoid sub-pixel rounding pushing the 3rd item to a new row │
│ + flex: 1 1 calc(33.333% - #{$spacing-lg * 2 * 0.333} - 1px); │
│ + max-width: calc(33.333% - #{$spacing-lg * 2 * 0.333} - 1px); │
│ + min-width: 280px; │
│ + } │
│ + │
│ + // Mobile overrides - placed at the end of the stylesheet so they win the │
│ + // cascade over the desktop rules above (same selector specificity). │
│ + @media (max-width: 768px) { │
│ + .home { │
│ + padding: 15px; │
│ + } │
│ + │
│ + // Mirror LeaderboardHeader's mobile font-size drop (~1.5em). │
│ + .home__hero h1 { │
│ + font-size: 1.5em; │
│ + } │
│ + │
│ + // Stack dungeon cards full-width on mobile so TeamComposition's vertical │
│ + // member stack (its own ~720px breakpoint) has room to read; the 2-col 165px │
│ + // grid otherwise overlapped player names with time/date. │
│ + .home__dungeon-grid > :global(.dungeon-record-card) { │
│ + flex: 1 1 100%; │
│ + max-width: 100%; │
│ + } │
│ + │
│ + // Player lists also fill the row; otherwise the 280px min-width caps each │
│ + // list below the available content area, leaving wasted right-margin. │
│ + .home__players-grid > :global(.top-players-list) { │
│ + flex: 1 1 100%; │
│ + max-width: 100%; │
│ + min-width: 0; │
│ + } │
│ + } │
│ +</style> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/pages/index.astro ──────────┐
│ diff --git a/web/src/pages/index.astro b/web │
│ /src/pages/index.astro │
│ index 5041278..7c4d694 100644 │
│ --- a/web/src/pages/index.astro │
│ +++ b/web/src/pages/index.astro │
│ @@ -1,15 +1,266 @@ │
│ --- │
│ -import SimulationLayout from "../layouts/Si │
│ mulationLayout.astro"; │
│ +import PageLayout from "../layouts/PageLayo │
│ ut.astro"; │
│ +import PlayerSearch from "../components/Pla │
│ yerSearch/PlayerSearch.astro"; │
│ +import LeaderboardTypeNav from "../componen │
│ ts/Leaderboard/LeaderboardTypeNav/Leaderboar │
│ dTypeNav.astro"; │
│ +import RecentRuns from "../components/Home/ │
│ RecentRuns/RecentRuns.astro"; │
│ +import DungeonRecordCard from "../component │
│ s/Home/DungeonRecordCard/DungeonRecordCard.a │
│ stro"; │
│ +import TopPlayersList from "../components/H │
│ ome/TopPlayersList/TopPlayersList.astro"; │
│ + │
│ +export const prerender = false; │
│ + │
│ +let home: any = null; │
│ +try { │
│ + const response = await fetch(`${Astro.url │
│ .origin}/api/home.json`); │
│ + if (response.ok) { │
│ + home = await response.json(); │
│ + } │
│ +} catch (error) { │
│ + console.error("[Home] Failed to load home │
│ .json:", error); │
│ +} │
│ + │
│ +// Pick the latest season (highest key) as │
│ the focus │
│ +const seasonKeys = home ? Object.keys(home. │
│ seasons).sort((a, b) => parseInt(b) - parseI │
│ nt(a)) : []; │
│ +const currentSeasonKey = seasonKeys[0]; │
│ +const currentSeason = currentSeasonKey ? ho │
│ me.seasons[currentSeasonKey] : null; │
│ + │
│ +const recentRuns = home?.recent_top_runs ?? │
│ []; │
│ + │
│ +// Sort dungeons by ID for consistent layou │
│ t │
│ +const dungeonEntries = currentSeason │
│ + ? Object.entries(currentSeason.top_runs_p │
│ er_dungeon as Record<string, any[]>) │
│ + .filter(([_, runs]) => runs && runs.l │
│ ength > 0) │
│ + .sort(([_, a], [__, b]) => (a[0]?.dun │
│ geon_id ?? 0) - (b[0]?.dungeon_id ?? 0)) │
│ + : []; │
│ + │
│ +const generatedAt = home?.generated_at ? ne │
│ w Date(home.generated_at) : null; │
│ --- │
│ │
│ -<SimulationLayout title="Simulations" descr │
│ iption="Redirecting..."> │
│ - <div class="card"> │
│ - <p>Redirecting to benchmarks...</p> │
│ +<PageLayout │
│ + title="WoW Mists of Pandaria Challenge Mo │
│ de Leaderboards" │
│ + description="Top challenge mode times, re │
│ cent record-setting runs, and the world's be │
│ st players for World of Warcraft: Mists of P │
│ andaria Classic." │
│ +> │
│ + <div class="home"> │
│ + { │
│ + !home && ( │
│ + <div class="home__error"> │
│ + <h2>Couldn't load home data</h2> │
│ + <p>The home.json file is missing │
│ or unreachable. Try regenerating it via <cod │
│ e>ookstats generate home</code>.</p> │
│ + </div> │
│ + ) │
│ + } │
│ + │
│ + { │
│ + home && ( │
│ + <> │
│ + <header class="home__hero"> │
│ + <h1>Challenge Mode Leaderboards │
│ </h1> │
│ + {generatedAt && ( │
│ + <span class="home__updated"> │
│ + Data updated {generatedAt.t │
│ oLocaleString("en-US", { month: "short", day │
│ : "numeric", hour: "2-digit", minute: "2-dig │
│ it" })} │
│ + </span> │
│ + )} │
│ + </header> │
│ + │
│ + <PlayerSearch /> │
│ + │
│ + <LeaderboardTypeNav │
│ + currentTab="home" │
│ + currentSeason={parseInt(current │
│ SeasonKey)} │
│ + /> │
│ + │
│ + {currentSeason && ( │
│ + <section class="home__section"> │
│ + <header class="home__section- │
│ header"> │
│ + <h2>Top Players - {currentS │
│ eason.season_name || `Season ${currentSeason │
│ Key}`}</h2> │
│ + </header> │
│ + <div class="home__players-gri │
│ d"> │
│ + <TopPlayersList │
│ + title="Global" │
│ + scope="global" │
│ + players={currentSeason.to │
│ p_players.global} │
│ + viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/globa │
│ l`} │
│ + /> │
│ + <TopPlayersList │
│ + title="US" │
│ + scope="regional" │
│ + players={currentSeason.to │
│ p_players.us} │
│ + viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/us`} │
│ + /> │
│ + <TopPlayersList │
│ + title="EU" │
│ + scope="regional" │
│ + players={currentSeason.to │
│ p_players.eu} │
│ + viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/eu`} │
│ + /> │
│ + <TopPlayersList │
│ + title="KR" │
│ + scope="regional" │
│ + players={currentSeason.to │
│ p_players.kr} │
│ + viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/kr`} │
│ + /> │
│ + <TopPlayersList │
│ + title="TW" │
│ + scope="regional" │
│ + players={currentSeason.to │
│ p_players.tw} │
│ + viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/tw`} │
│ + /> │
│ + </div> │
│ + </section> │
│ + )} │
│ + │
│ + {currentSeason && ( │
│ + <section class="home__section"> │
│ + <header class="home__section- │
│ header"> │
│ + <h2>Dungeon Records - {curr │
│ entSeason.season_name || `Season ${currentSe │
│ asonKey}`}</h2> │
│ + </header> │
│ + <div class="home__dungeon-gri │
│ d"> │
│ + {dungeonEntries.map(([slug, │
│ runs]) => ( │
│ + <DungeonRecordCard │
│ + dungeonName={runs[0].du │
│ ngeon_name} │
│ + dungeonSlug={slug} │
│ + runs={runs} │
│ + seasonId={parseInt(curr │
│ entSeasonKey)} │
│ + /> │
│ + ))} │
│ + </div> │
│ + </section> │
│ + )} │
│ + │
│ + <section class="home__section"> │
│ + <RecentRuns runs={recentRuns} / │
│ > │
│ + </section> │
│ + </> │
│ + ) │
│ + } │
│ </div> │
│ - <script is:inline> │
│ - (function () { │
│ - var url = new URL(window.location.hre │
│ f); │
│ - window.location.replace("/simulation/ │
│ benchmark" + url.search); │
│ - })(); │
│ - </script> │
│ -</SimulationLayout> │
│ +</PageLayout> │
│ + │
│ +<style lang="scss"> │
│ + @use "../styles/core/tokens" as *; │
│ + │
│ + // Match CM page wrapper (.leaderboard-pa │
│ ge on /challenge-mode/* pages) │
│ + // so navigating between sections feels c │
│ ohesive. No flex-gap on the wrapper - │
│ + // each child component (LeaderboardHeade │
│ r-style hero, PlayerSearch, nav) carries │
│ + // its own bottom margin to match the CM │
│ page rhythm. │
│ + .home { │
│ + max-width: 1200px; │
│ + margin: 0 auto; │
│ + padding: 20px; │
│ + } │
│ + │
│ + .home__error { │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + border-radius: $radius-lg; │
│ + padding: $spacing-2xl; │
│ + text-align: center; │
│ + color: var(--text-secondary); │
│ + code { │
│ + background: var(--bg-primary); │
│ + padding: 2px 6px; │
│ + border-radius: $radius-sm; │
│ + color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + // Mirror LeaderboardHeader's spacing/typ │
│ ography exactly so home title flows │
│ + // visually like every other CM-section p │
│ age header. │
│ + .home__hero { │
│ + display: flex; │
│ + align-items: baseline; │
│ + justify-content: space-between; │
│ + gap: $spacing-lg; │
│ + flex-wrap: wrap; │
│ + margin: 8px 0 16px 0; │
│ + │
│ + h1 { │
│ + margin: 0; │
│ + font-size: 2em; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-bold; │
│ + } │
│ + } │
│ + │
│ + .home__updated { │
│ + color: var(--text-tertiary); │
│ + font-size: $font-size-sm; │
│ + } │
│ + │
│ + .home__section { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-lg; │
│ + │
│ + & + & { │
│ + margin-top: $spacing-2xl; │
│ + } │
│ + } │
│ + │
│ + .home__section-header { │
│ + h2 { │
│ + margin: 0; │
│ + font-size: $font-size-2xl; │
│ + color: var(--text-primary); │
│ + font-weight: $font-weight-semibold; │
│ + } │
│ + } │
│ + │
│ + // Flexbox (not grid) so an incomplete la │
│ st row centers horizontally. │
│ + // The `:global()` is needed because Astr │
│ o scopes `> *` to local elements, │
│ + // but the children are component roots f │
│ rom DungeonRecordCard / TopPlayersList. │
│ + .home__dungeon-grid { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + justify-content: center; │
│ + gap: $spacing-md $spacing-lg; │
│ + } │
│ + │
│ + .home__dungeon-grid > :global(.dungeon-re │
│ cord-card) { │
│ + flex: 1 1 calc(50% - #{$spacing-lg * 0. │
│ 5}); │
│ + max-width: calc(50% - #{$spacing-lg * 0 │
│ .5}); │
│ + min-width: 0; │
│ + } │
│ + │
│ + .home__players-grid { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + justify-content: center; │
│ + gap: $spacing-lg; │
│ + } │
│ + │
│ + .home__players-grid > :global(.top-player │
│ s-list) { │
│ + // 1px fudge to avoid sub-pixel roundin │
│ g pushing the 3rd item to a new row │
│ + flex: 1 1 calc(33.333% - #{$spacing-lg │
│ * 2 * 0.333} - 1px); │
│ + max-width: calc(33.333% - #{$spacing-lg │
│ * 2 * 0.333} - 1px); │
│ + min-width: 280px; │
│ + } │
│ + │
│ + // Mobile overrides - placed at the end o │
│ f the stylesheet so they win the │
│ + // cascade over the desktop rules above ( │
│ same selector specificity). │
│ + @media (max-width: 768px) { │
│ + .home { │
│ + padding: 15px; │
│ + } │
│ + │
│ + // Mirror LeaderboardHeader's mobile fo │
│ nt-size drop (~1.5em). │
│ + .home__hero h1 { │
│ + font-size: 1.5em; │
│ + } │
│ + │
│ + // Stack dungeon cards full-width on mo │
│ bile so TeamComposition's vertical │
│ + // member stack (its own ~720px breakpo │
│ int) has room to read; the 2-col 165px │
│ + // grid otherwise overlapped player nam │
│ es with time/date. │
│ + .home__dungeon-grid > :global(.dungeon- │
│ record-card) { │
│ + flex: 1 1 100%; │
│ + max-width: 100%; │
│ + } │
│ + │
│ + // Player lists also fill the row; othe │
│ rwise the 280px min-width caps each │
│ + // list below the available content are │
│ a, leaving wasted right-margin. │
│ + .home__players-grid > :global(.top-play │
│ ers-list) { │
│ + flex: 1 1 100%; │
│ + max-width: 100%; │
│ + min-width: 0; │
│ + } │
│ + } │
│ +</style> │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET