Use compact player record cards on mobile
This commit is contained in:
@@ -35,6 +35,13 @@ History pagination retains the selection and return link. Map links preserve
|
||||
the selected ranking metric. A player without qualifying matches in a selection
|
||||
can still view their lifetime profile and choose another scope.
|
||||
|
||||
On phones, per-map records and match history use compact cards with the map,
|
||||
game, highest round and kills up front. Expand **All stats and record runs**
|
||||
or **More match stats** for the remaining counters and source evidence. Desktop
|
||||
profiles retain the complete tables; both views use the same history filters
|
||||
and pagination. History dates say **First observed**, since a missing start
|
||||
report can make the first recorded observation later than the actual game start.
|
||||
|
||||
Highest-round, best-game-kill and highest-score records link to the completed
|
||||
match that earned them on the leaderboard and in lifetime/per-map profile stats.
|
||||
Each source shows its map, game, completion time, server, and whether the match
|
||||
|
||||
@@ -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>
|
||||
@@ -10,6 +10,8 @@
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
import PlayerMapCards from '$ui/components/PlayerMapCards.svelte';
|
||||
import PlayerMatchCards from '$ui/components/PlayerMatchCards.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { data }: { data: { profile: PlayerProfile; returnTo: string;
|
||||
@@ -101,8 +103,9 @@
|
||||
|
||||
<section class="panel" aria-labelledby="maps-title">
|
||||
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat across both games, ordered by highest round.</p></div>
|
||||
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
|
||||
@@ -116,10 +119,11 @@
|
||||
<section class="panel" aria-labelledby="history-title">
|
||||
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches in the selected game and map, most recently observed first. Stats reflect this player's participation.</p></div>
|
||||
{#if profile.matches.length}
|
||||
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
||||
<thead><tr><th scope="col">Map / server</th><th scope="col">First observed</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
||||
<tbody>{#each profile.matches as match (match.id)}
|
||||
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
|
||||
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
|
||||
@@ -129,7 +133,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} games</span>
|
||||
<div><Button size="sm" href={profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div>
|
||||
<div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div>
|
||||
</div>
|
||||
{:else}<EmptyState title="No completed matches in this selection" description="Choose another game or map to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
|
||||
</section>
|
||||
@@ -142,7 +146,7 @@
|
||||
<style>
|
||||
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
|
||||
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); scroll-margin-top: 6rem; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.stat { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
@@ -160,6 +164,7 @@
|
||||
.scope-rank { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 var(--space-5) var(--space-5); font-size: var(--text-sm); }
|
||||
.scope-rank p { margin: 0; }
|
||||
section > .note { margin-top: var(--space-3); }
|
||||
.mobile-records { display: none; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; }
|
||||
@@ -172,5 +177,5 @@
|
||||
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .scope-controls { grid-template-columns: minmax(0, 1fr); } .scope-rank { align-items: flex-start; flex-direction: column; } }
|
||||
@media (max-width: 640px) { .mobile-records { display: block; } .desktop-records { display: none; } .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .scope-controls { grid-template-columns: minmax(0, 1fr); } .scope-rank { align-items: flex-start; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user