Focus player records and add squad leaderboards
This commit is contained in:
@@ -12,15 +12,17 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const pageSize = leaderboardPageSize;
|
||||
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
|
||||
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
|
||||
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed'];
|
||||
const squadSizes = [{ key: '1', label: 'Solo' }, { key: '2', label: 'Duo' }, { key: '3', label: 'Trio' }, { key: '4', label: 'Four players' }];
|
||||
const descriptions: Record<PlayerStatMetric, string> = {
|
||||
highestRound: 'The highest round reached in a recorded game.',
|
||||
highestRound: 'The highest recorded round, including players who joined mid-game.',
|
||||
kills: 'Cumulative kills across recorded games.',
|
||||
roundsSurvived: 'Round progression during observed participation, added across games.',
|
||||
headshots: 'Cumulative headshots across recorded games.',
|
||||
@@ -41,9 +43,10 @@
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)));
|
||||
let checkedAt = $state<Date | null>(null);
|
||||
let checkedAt = $state<Date | null>(untrack(() => initial.leaderboard ? new Date() : null));
|
||||
let pendingUpdate = $state<LeaderboardResponse | null>(null);
|
||||
let expandedPlayers = $state<string[]>([]);
|
||||
let filtersOpen = $state(false);
|
||||
let showAllStats = $state(false);
|
||||
let tableRegion = $state<HTMLDivElement>();
|
||||
let controller: AbortController | undefined;
|
||||
let requestId = 0;
|
||||
@@ -53,6 +56,8 @@
|
||||
|
||||
const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : '');
|
||||
const map = $derived($page.url.searchParams.get('map') ?? '');
|
||||
const players = $derived(squadSizes.some(size => size.key === $page.url.searchParams.get('players')) ? $page.url.searchParams.get('players')! : '');
|
||||
const playersLabel = $derived(squadSizes.find(size => size.key === players)?.label ?? 'All squad sizes');
|
||||
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
|
||||
const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound');
|
||||
const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))));
|
||||
@@ -60,11 +65,13 @@
|
||||
const mapName = $derived(maps.find(item => item.key === map)?.name);
|
||||
const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps'));
|
||||
const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label);
|
||||
const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]);
|
||||
const columns = $derived([metric, ...(showAllStats ? playerStatMetrics.map(item => item.key) : defaultColumns).filter(key => key !== metric)]);
|
||||
const filterCount = $derived([game, map, players, query].filter(Boolean).length);
|
||||
const apiUrl = $derived.by(() => {
|
||||
const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
|
||||
if (game) params.set('game', game);
|
||||
if (map) params.set('map', map);
|
||||
params.set('map', map);
|
||||
if (players) params.set('players', players);
|
||||
if (query) params.set('q', query);
|
||||
return `/api/leaderboard?${params}`;
|
||||
});
|
||||
@@ -76,8 +83,8 @@
|
||||
const report = initial.leaderboard;
|
||||
untrack(() => {
|
||||
++requestId; controller?.abort(); data = report; maps = report.maps;
|
||||
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null;
|
||||
pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report);
|
||||
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = new Date();
|
||||
pendingUpdate = null; updates.reset(apiUrl, report);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,8 +114,7 @@
|
||||
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
|
||||
const result: LeaderboardResponse = await response.json();
|
||||
if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
|
||||
const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement)
|
||||
|| !!tableRegion?.querySelector('details[open]');
|
||||
const inspecting = !!tableRegion?.contains(document.activeElement);
|
||||
const next = updates.receive(url, result, inspecting);
|
||||
if (!next) return;
|
||||
data = next.current;
|
||||
@@ -126,25 +132,18 @@
|
||||
}
|
||||
|
||||
function applyUpdate() {
|
||||
expandedPlayers = [];
|
||||
tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open'));
|
||||
data = updates.apply();
|
||||
maps = data.maps;
|
||||
pendingUpdate = null;
|
||||
}
|
||||
|
||||
function togglePlayer(guid: string) {
|
||||
expandedPlayers = expandedPlayers.includes(guid)
|
||||
? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid];
|
||||
}
|
||||
|
||||
function updateFilters(changes: Record<string, string>) {
|
||||
function updateFilters(changes: Record<string, string | null>) {
|
||||
++requestId; controller?.abort(); loading = false;
|
||||
// Keep rapid changes together while the preceding navigation settles.
|
||||
const url = new URL(pendingUrl ?? $page.url);
|
||||
url.searchParams.delete('offset');
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
if (value || key === 'map' && value !== null) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
pendingUrl = url;
|
||||
@@ -156,7 +155,7 @@
|
||||
|
||||
function clearFilters() {
|
||||
search = '';
|
||||
updateFilters({ game: '', map: '', q: '' });
|
||||
updateFilters({ game: '', map: null, players: '', q: '' });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -166,40 +165,49 @@
|
||||
</svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<SectionHeader title="Leaderboard" subtitle="All-time player records.">
|
||||
{#snippet actions()}
|
||||
<Button size="sm" loading={loading} disabled={changingFilters} onclick={() => void refresh(apiUrl)}>Refresh</Button>
|
||||
{/snippet}
|
||||
<SectionHeader title="Leaderboard" subtitle="Records by map and starting squad.">
|
||||
{#snippet actions()}<div class="mobile-filter-trigger"><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="filters">
|
||||
<div class="field"><label for="leaderboard-game">Game</label>
|
||||
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}>
|
||||
<option value="">All games</option>
|
||||
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
<div class="field"><label for={`leaderboard-game${suffix}`}>Game</label>
|
||||
<select id={`leaderboard-game${suffix}`} value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: null })}>
|
||||
<option value="">All games</option>{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for={`leaderboard-map${suffix}`}>Map</label>
|
||||
<select id={`leaderboard-map${suffix}`} value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
|
||||
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for={`leaderboard-players${suffix}`}>Players</label>
|
||||
<select id={`leaderboard-players${suffix}`} value={players} onchange={event => updateFilters({ players: event.currentTarget.value })} aria-describedby={`players-help${suffix}`}>
|
||||
<option value="">All squad sizes</option>{#each squadSizes as size}<option value={size.key}>{size.label}</option>{/each}
|
||||
</select><span id={`players-help${suffix}`} class="sr-only">Players recorded at the start of each match. Matches with unknown starting counts appear only under All squad sizes.</span>
|
||||
</div>
|
||||
<div class="field"><label for={`leaderboard-metric${suffix}`}>Rank by</label>
|
||||
<select id={`leaderboard-metric${suffix}`} value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
|
||||
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); if (suffix) filtersOpen = false; }} role="search">
|
||||
<label for={`leaderboard-search${suffix}`}>Find a player</label>
|
||||
<input id={`leaderboard-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby={`search-help${suffix}`} /><span id={`search-help${suffix}`} class="sr-only">Press Enter to search. Player ranks are preserved.</span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="field"><label for="leaderboard-map">Map</label>
|
||||
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
|
||||
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game.toUpperCase()}` : ''}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for="leaderboard-metric">Rank by</label>
|
||||
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
|
||||
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
|
||||
<label for="leaderboard-search">Find a player</label>
|
||||
<input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby="search-help" /><span id="search-help" class="sr-only">Press Enter to search. Player ranks are preserved.</span>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<Dialog bind:open={filtersOpen} title="Leaderboard filters" description="Choose a map, starting squad size, and ranking." drawer>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={clearFilters}>Reset filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<div class="scope-row">
|
||||
<div><span class="scope">{scope}</span><span class="all-time">All time</span></div>
|
||||
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if}
|
||||
<div><span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span class="all-time">All time</span></div>
|
||||
{#if game || players || query}<button class="text-button" onclick={clearFilters}>Reset filters</button>{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
@@ -215,13 +223,13 @@
|
||||
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Unique players</small></div>
|
||||
</section>
|
||||
|
||||
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
|
||||
<section class="rankings" class:show-all-stats={showAllStats} aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
|
||||
<div class="rankings-header">
|
||||
<div><h2 id="rankings-title">{metricLabel}</h2>
|
||||
{#if isEfficiencyMetric(metric)}<p>{efficiencyRequirement(metric)} within the selected game and map.</p>{/if}
|
||||
<details class="metric-help"><summary>About this ranking</summary><p>{description(metric)} Equal values share a rank.</p></details>
|
||||
<p class="metric-description">{description(metric)} Equal values share a rank.</p>
|
||||
</div>
|
||||
<div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Checking…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : 'Checks every 15 seconds'}</div>
|
||||
<div class="ranking-controls"><label class="all-stats"><input type="checkbox" bind:checked={showAllStats} /> Show all stats</label>{#if !error}<Freshness updatedAt={checkedAt?.getTime() ?? null} intervalLabel="Updates every 15 seconds" />{/if}</div>
|
||||
</div>
|
||||
{#if pendingUpdate}<div class="pending-update" role="status"><button class="text-button" onclick={applyUpdate} disabled={changingFilters}>Updated rankings available — apply update</button></div>{/if}
|
||||
{#if data.entries.length}
|
||||
@@ -244,7 +252,7 @@
|
||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||
<th scope="row" class="player">
|
||||
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
|
||||
<button class="text-button" aria-expanded={expandedPlayers.includes(entry.guid)} aria-controls={`player-details-${entry.guid}`} onclick={() => togglePlayer(entry.guid)}>{expandedPlayers.includes(entry.guid) ? 'Hide details' : 'Details'}<span class="sr-only"> for {entry.name}</span></button>
|
||||
|
||||
</th>
|
||||
{#each columns as key (key)}
|
||||
<td class="stat" class:selected={key === metric}>
|
||||
@@ -252,24 +260,12 @@
|
||||
{#if isEfficiencyMetric(key)}
|
||||
<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>
|
||||
{/if}
|
||||
{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}
|
||||
<small class="record-map">{entry.records[key].mapName}</small>
|
||||
<details class="record-details">
|
||||
<summary>Record details<span class="sr-only"> for {entry.name}</span></summary>
|
||||
<RecordSource evidence={entry.records[key]} disclosure={false} returnTo={`${$page.url.pathname}${$page.url.search}`} />
|
||||
</details>
|
||||
{#if key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
|
||||
<RecordSource evidence={entry.records[key]} metric={metricLabel} returnTo={`${$page.url.pathname}${$page.url.search}`} />
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{#if expandedPlayers.includes(entry.guid)}
|
||||
<tr class="player-details" id={`player-details-${entry.guid}`}><td colspan={columns.length + 2}>
|
||||
<div class="identity">GUID <CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
||||
<dl>{#each playerStatMetrics.filter(item => !columns.includes(item.key)) as stat (stat.key)}
|
||||
<div><dt>{stat.label}</dt><dd>{formatValue(entry, stat.key)}</dd></div>
|
||||
{/each}</dl>
|
||||
</td></tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -282,9 +278,9 @@
|
||||
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
|
||||
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else if query || game || map || offset}
|
||||
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
|
||||
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
|
||||
{:else if query || game || map || players || offset}
|
||||
<EmptyState spacious title="No players found" description="Try a different game, map, starting squad size, or player name.">
|
||||
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Reset filters</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<EmptyState spacious title="Your first records start here" description="Player stats appear after a game ends, once the player has been observed at round 2 or later." />
|
||||
@@ -294,11 +290,12 @@
|
||||
<div class="loading" role="status">Loading player records…</div>
|
||||
{/if}
|
||||
|
||||
{#if data}<details class="mobile-totals"><summary>Selection totals</summary><dl><div><dt>Highest round</dt><dd>{number.format(data.summary.highestRound)}</dd></div><div><dt>Total kills</dt><dd>{number.format(data.summary.kills)}</dd></div><div><dt>Total rounds</dt><dd>{number.format(data.summary.roundsSurvived)}</dd></div><div><dt>Games tracked</dt><dd>{number.format(data.summary.matches)}</dd></div><div><dt>Players</dt><dd>{number.format(data.summary.players)}</dd></div></dl></details>{/if}
|
||||
<details class="tracking-notes">
|
||||
<summary>How these records work</summary>
|
||||
<div>
|
||||
<p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p>
|
||||
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
|
||||
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
|
||||
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
|
||||
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
|
||||
@@ -308,24 +305,18 @@
|
||||
|
||||
<style>
|
||||
.overview { display: grid; grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||
.overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); padding: 24px; border-right: 1px solid var(--border-subtle); min-width: 0; }
|
||||
.overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-1); padding: var(--space-4); border-right: 1px solid var(--border-subtle); min-width: 0; }
|
||||
.overview > div:last-child { border-right: 0; }
|
||||
.overview span, .overview small { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.overview strong { font-size: clamp(28px, 3vw, 40px); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
||||
.overview .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 16%, transparent), transparent); }
|
||||
.record strong { color: var(--accent-primary-hover); font-size: clamp(36px, 4vw, 52px); }
|
||||
.metric-help, .record-details { color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); margin-top: 4px; white-space: normal; }
|
||||
.overview strong { font-size: var(--text-xl); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
||||
.overview .record { background: var(--surface-raised); }
|
||||
.record strong { color: var(--text-primary); font-size: var(--text-2xl); }
|
||||
summary { cursor: pointer; }
|
||||
.metric-help p { margin-top: 8px; }
|
||||
.record-map { display: block; margin-top: 4px; color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); white-space: normal; }
|
||||
.pending-update { padding: 0 var(--space-5) var(--space-3); }
|
||||
.player-details td { background: var(--surface-sunken); }
|
||||
.player-details dl { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 12px 0 0; }
|
||||
.player-details dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.player-details dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
|
||||
.text-button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); }
|
||||
.filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.search { grid-column: 1 / -1; }
|
||||
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||
label { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
input, select, button { font: inherit; }
|
||||
@@ -340,21 +331,20 @@
|
||||
.rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); }
|
||||
h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
.rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; }
|
||||
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||
thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
||||
.rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; }
|
||||
.rank .podium { border-radius: var(--radius-sm); color: var(--accent-primary-hover); background: color-mix(in srgb, var(--accent-primary) 12%, transparent); font-weight: var(--weight-semibold); }
|
||||
.rank .podium { border-radius: var(--radius-sm); color: var(--text-primary); background: var(--surface-raised); font-weight: var(--weight-semibold); }
|
||||
.player { position: sticky; left: 0; z-index: 1; min-width: 180px; max-width: 320px; text-align: left; }
|
||||
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
|
||||
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.stat.selected { color: var(--accent-primary-hover); }
|
||||
td.stat { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.stat.selected { color: var(--text-primary); }
|
||||
th.stat.selected { color: var(--accent-primary-hover); }
|
||||
.sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); }
|
||||
td.selected { font-weight: var(--weight-semibold); }
|
||||
th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; }
|
||||
@@ -370,6 +360,13 @@
|
||||
.tracking-notes > div { max-width: 100ch; }
|
||||
.tracking-notes p { margin: var(--space-3) 0 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.ranking-controls { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
||||
.all-stats { display: flex; align-items: center; gap: var(--space-1); white-space: nowrap; font-size: var(--text-xs); }
|
||||
.all-stats input { width: 16px; height: 16px; min-height: 0; accent-color: var(--accent-primary); }
|
||||
.mobile-filter-trigger, .mobile-totals { display: none; }
|
||||
.mobile-totals { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.mobile-totals dl { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
||||
.mobile-totals dd { margin: var(--space-1) 0 0; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 800px) {
|
||||
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -382,10 +379,20 @@
|
||||
.page { gap: var(--space-4); }
|
||||
.filters { gap: var(--space-3); }
|
||||
.search { grid-column: 1 / -1; }
|
||||
.filters > .field:nth-child(3) { grid-column: 1 / -1; }
|
||||
.desktop-filters, .overview { display: none; }
|
||||
.mobile-filter-trigger, .mobile-totals { display: block; }
|
||||
.scope-row { font-size: var(--text-sm); }
|
||||
.scope-row > div { gap: var(--space-1) var(--space-2); }
|
||||
.all-time { padding-left: var(--space-2); }
|
||||
.filters { grid-template-columns: minmax(0, 1fr); }
|
||||
.ranking-controls { justify-content: flex-start; }
|
||||
.metric-description { display: none; }
|
||||
.rankings:not(.show-all-stats) .stat:not(.selected) { display: none; }
|
||||
.rank { width: 36px; }
|
||||
.rank span { min-width: 20px; }
|
||||
.player { position: static; min-width: 90px; max-width: 140px; }
|
||||
.selected :global(.record-source) { max-width: 150px; }
|
||||
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
|
||||
.refresh-status { text-align: left; }
|
||||
.player { min-width: 180px; max-width: 220px; }
|
||||
th, td { padding: var(--space-3); }
|
||||
.pagination { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user