Focus player records and add squad leaderboards
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let { updatedAt = null, error = '', intervalLabel = '', onRetry }: { updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void } = $props();
|
||||
let now = $state(Date.now());
|
||||
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
|
||||
const age = $derived(updatedAt === null ? null : Math.max(0, Math.floor((now - updatedAt) / 1000)));
|
||||
const ago = $derived(age === null ? '' : age < 5 ? 'just now' : age < 60 ? `${age} s ago` : `${Math.floor(age / 60)} min ago`);
|
||||
</script>
|
||||
<div class="freshness" class:failed={!!error}>
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button type="button" onclick={onRetry}>Retry</button>{/if}
|
||||
{:else}<span>{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
|
||||
</div>
|
||||
<style>
|
||||
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.failed { color: var(--state-warning); }
|
||||
button { background: none; border: 0; padding: var(--space-1); color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
</style>
|
||||
@@ -11,6 +11,8 @@
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
|
||||
let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props();
|
||||
let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
|
||||
@@ -18,6 +20,8 @@
|
||||
let error = $state('');
|
||||
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let tableRegion = $state<HTMLDivElement>();
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
@@ -29,13 +33,14 @@
|
||||
return `${endpoint}?${params}`;
|
||||
});
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
|
||||
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
|
||||
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
|
||||
|
||||
$effect(() => { search = filters.q; });
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; });
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
@@ -49,7 +54,7 @@
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
// Do not move keyboard users to another match while they are following a link.
|
||||
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
|
||||
loadedUrl = url; loading = false; error = '';
|
||||
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load match history.';
|
||||
@@ -71,27 +76,33 @@
|
||||
</script>
|
||||
|
||||
<section class="history" aria-label="Match history">
|
||||
<div class="filters">
|
||||
{#if server === undefined}
|
||||
<label>Game<select value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
{/if}
|
||||
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game.toUpperCase()}` : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if server === undefined}
|
||||
<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); }}>
|
||||
<label for="match-search">Instance<input id="match-search" type="search" bind:value={search} maxlength="100" placeholder="Find an instance…" aria-describedby="match-search-help" /></label>
|
||||
<span id="match-search-help" class="sr-only">Press Enter to search by instance name.</span>
|
||||
</form>
|
||||
{/if}
|
||||
<label>Date range<select value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||||
<details class="more-filters"><summary>More filters{filters.status ? ' (1)' : ''}</summary>
|
||||
<label>Status<select value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||||
</details>
|
||||
</div>
|
||||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}</div>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
{#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
|
||||
<label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
|
||||
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
|
||||
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
|
||||
</form>{/if}
|
||||
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||||
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||||
{#if suffix}
|
||||
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
|
||||
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
{#if loading}<EmptyState title="Loading matches…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
|
||||
@@ -112,11 +123,11 @@
|
||||
<tr>
|
||||
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
|
||||
<td class="match">
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map}</a>
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"><time datetime={match.startedAt}>{matchStartedLabel(match.startedAt, $timeZone)}</time> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
|
||||
{#if server === undefined}<div class="instance">
|
||||
{#if !match.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(match.server)}?tab=matches`, $page.url)} title={match.server}>{matchInstanceLabel(match)}</a>
|
||||
{:else}<span title={match.server}>{matchInstanceLabel(match)}</span><span> · Historical instance</span>{/if}
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
|
||||
</div>{/if}
|
||||
</td>
|
||||
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
|
||||
@@ -143,12 +154,10 @@
|
||||
label, form { flex: 1; min-width: 145px; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
.more-filters { padding-top: 25px; font-size: var(--text-sm); }
|
||||
.more-filters label { margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pagination > div { display: flex; gap: var(--space-2); }
|
||||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
@@ -165,7 +174,6 @@
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.instance a { color: inherit; text-decoration: none; }
|
||||
.status { color: var(--text-secondary); }
|
||||
.status.active { color: var(--accent-primary-hover); }
|
||||
.status.interrupted { color: var(--state-warning); }
|
||||
@@ -173,5 +181,17 @@
|
||||
.about p { max-width: 90ch; line-height: 1.6; }
|
||||
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) { .filters > label, .filters > form { flex-basis: 45%; min-width: 120px; } .more-filters { padding-top: 0; width: 100%; } th, td { padding: 10px 12px; } }
|
||||
.mobile-filters, .mobile-match-context { display: none; }
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-match-context { display: block; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.filters > label, .filters > form { min-width: 0; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
th, td { padding: 10px 12px; }
|
||||
th:nth-child(1), td:nth-child(1) { display: none; }
|
||||
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
.match { min-width: 130px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMapRecord, type PersonalBestMetric, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { type PlayerMapRecord, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { contextHref } from '$lib/navigation-context.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();
|
||||
let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $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[];
|
||||
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
|
||||
</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>
|
||||
<header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
|
||||
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></dd></div>{/each}</dl>
|
||||
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
|
||||
</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; }
|
||||
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: var(--text-md); }
|
||||
header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; }
|
||||
dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
|
||||
dd a { display: inline-flex; align-items: center; min-height: 44px; }
|
||||
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>
|
||||
|
||||
@@ -1,64 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMatch } from '$lib/leaderboard.js';
|
||||
import 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();
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $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>
|
||||
<h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
|
||||
<p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
|
||||
<p><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time> · {match.completed ? 'Completion credited' : 'No completion credit'}</p>
|
||||
<dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
|
||||
</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; }
|
||||
article { 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); }
|
||||
h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
|
||||
p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-3); }
|
||||
dd { margin-top: var(--space-1); font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@@ -2,36 +2,10 @@
|
||||
import type { RecordEvidence } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
|
||||
let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props();
|
||||
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
|
||||
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="record-source">
|
||||
<a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
|
||||
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
|
||||
{#snippet runDetails()}
|
||||
<dl>
|
||||
<div><dt>Instance</dt><dd>{evidence.server}</dd></div>
|
||||
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
|
||||
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
|
||||
</dl>
|
||||
<p>Recorded presence does not establish continuous survival.</p>
|
||||
{/snippet}
|
||||
{#if disclosure}<details><summary>Run details</summary>{@render runDetails()}</details>
|
||||
{:else}{@render runDetails()}{/if}
|
||||
</div>
|
||||
|
||||
<span class="record-source"><a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`} aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`} title={evidence.endedAt ? `Completed ${$timeFormat.dateTime(evidence.endedAt)}` : 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a></span>
|
||||
<style>
|
||||
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); }
|
||||
.record-source { display: block; margin-top: var(--space-1); white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: var(--leading-body); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
|
||||
details { margin-top: var(--space-1); }
|
||||
summary { cursor: pointer; }
|
||||
dl { margin: var(--space-2) 0; }
|
||||
dl > div + div { margin-top: var(--space-2); }
|
||||
dt { color: var(--text-tertiary); }
|
||||
dd { margin: 0; }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user