Use compact player record cards on mobile

This commit is contained in:
2026-10-01 12:26:35 -04:00
parent eac9ad4fbc
commit f10345ae79
4 changed files with 148 additions and 6 deletions
@@ -0,0 +1,66 @@
<script lang="ts">
import { playerStatMetrics, type PlayerMapRecord, type PersonalBestMetric, type LeaderboardMetric } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import RecordSource from '$ui/components/RecordSource.svelte';
let { maps, metric = 'highestRound' }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric } = $props();
const number = new Intl.NumberFormat('en-US');
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
const otherMetrics = playerStatMetrics.filter(metric => metric.key !== 'highestRound' && metric.key !== 'kills');
const recordMetrics = ['highestRound', 'bestKills', 'bestScore'] as const satisfies readonly PersonalBestMetric[];
</script>
<div class="map-cards">
{#each maps as map (`${map.game}:${map.key}`)}
<article class="map-card">
<header>
<p class="game">{gameName(map.game)}</p>
<h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3>
<p class="last-played">Last played <time datetime={map.lastSeen}>{$timeFormat.dateTime(map.lastSeen)}</time></p>
</header>
<dl class="primary-stats">
<div><dt>Highest round</dt><dd class="best">{number.format(map.highestRound)}</dd></div>
<div><dt>Total kills</dt><dd>{number.format(map.kills)}</dd></div>
</dl>
<details class="more-stats">
<summary aria-label={`All stats and record runs for ${map.name}, ${gameName(map.game)}`}>All stats and record runs</summary>
<dl class="additional-stats">
{#each otherMetrics as metric (metric.key)}
<div><dt>{metric.label}</dt><dd>{number.format(map[metric.key])}</dd></div>
{/each}
</dl>
<div class="record-runs">
{#each recordMetrics as key (key)}
<div class="record-run">
<h4>{playerStatMetrics.find(metric => metric.key === key)!.label} · {number.format(map[key])}</h4>
<RecordSource evidence={map.records[key]} />
</div>
{/each}
</div>
</details>
</article>
{/each}
</div>
<style>
.map-cards { min-width: 0; }
.map-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, h4, p, dl, dd { margin: 0; }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
h4 { font-size: var(--text-sm); line-height: 1.5; }
a { color: var(--accent-primary-hover); }
.game, .last-played { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
.last-played { margin-top: var(--space-2); }
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.primary-stats { margin-top: var(--space-4); }
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
dd { margin-top: var(--space-1); font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.primary-stats dd { font-size: var(--text-xl); }
.best { color: var(--accent-primary-hover); }
.more-stats { margin-top: var(--space-2); }
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
.record-runs { margin-top: var(--space-4); }
.record-run { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
.record-run + .record-run { margin-top: var(--space-4); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import { playerStatMetrics, type PlayerMatch } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '$ui/components/primitives/Badge.svelte';
let { matches }: { matches: PlayerMatch[] } = $props();
const number = new Intl.NumberFormat('en-US');
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
const otherMetrics = ['roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const;
</script>
<div class="match-cards">
{#each matches as match (match.id)}
<article class="match-card">
<header>
<p class="game">{gameName(match.game)}</p>
<h3>{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}</h3>
<p class="server">{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</p>
</header>
<dl class="match-info">
<div><dt>First observed</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
<div><dt>Match status</dt><dd><span class="status">{match.status}</span> · {match.completed ? 'Completion credited' : 'No completion credit'}</dd></div>
</dl>
<dl class="primary-stats">
<div><dt>Highest round</dt><dd class="best">{number.format(match.highestRound)}</dd></div>
<div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div>
</dl>
<details class="more-stats">
<summary aria-label={`More match stats for ${match.mapName} from ${$timeFormat.dateTime(match.startedAt)}`}>More match stats</summary>
<dl class="additional-stats">
{#each otherMetrics as key (key)}
<div><dt>{playerStatMetrics.find(metric => metric.key === key)!.label}</dt><dd>{number.format(match[key])}</dd></div>
{/each}
</dl>
<p class="observation">Last observed <time datetime={match.lastSeen}>{$timeFormat.dateTime(match.lastSeen)}</time></p>
<p class="match-id">Match #{match.id}</p>
</details>
</article>
{/each}
</div>
<style>
.match-cards { min-width: 0; }
.match-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
a { color: var(--accent-primary-hover); }
.game, .server, .observation, .match-id { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
.server { margin-top: var(--space-2); }
.match-info { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
dd { margin-top: var(--space-1); font-size: var(--text-sm); line-height: 1.5; }
.status { text-transform: capitalize; }
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.primary-stats { margin-top: var(--space-4); }
.primary-stats dd, .additional-stats dd { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.primary-stats dd { font-size: var(--text-xl); }
.best { color: var(--accent-primary-hover); }
.more-stats { margin-top: var(--space-2); }
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
.observation { margin-top: var(--space-4); }
.match-id { margin-top: var(--space-1); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style>