108 lines
6.3 KiB
Svelte
108 lines
6.3 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/stores';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import { goto } from '$app/navigation';
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import type { ServerMatch } from '$lib/match-history.js';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
import { formatDuration } from '$ui/duration.js';
|
||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||
import Card from './primitives/Card.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import EmptyState from './primitives/EmptyState.svelte';
|
||
import ListFooter from './primitives/ListFooter.svelte';
|
||
|
||
let { server }: { server?: string } = $props();
|
||
let matches = $state<ServerMatch[]>([]);
|
||
let total = $state(0);
|
||
let limit = $state(25);
|
||
$effect(() => { limit = Math.min(10000, Math.max(25, Number($page.url.searchParams.get('matches')) || 25)); });
|
||
function saveContext() {
|
||
const url = new URL($page.url);
|
||
if (limit > 25) url.searchParams.set('matches', String(limit)); else url.searchParams.delete('matches');
|
||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||
}
|
||
let loading = $state(true);
|
||
let error = $state('');
|
||
|
||
async function refresh(currentServer: string | undefined, currentLimit: number) {
|
||
try {
|
||
const endpoint = currentServer === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(currentServer)}/matches`;
|
||
const response = await fetch(`${endpoint}?limit=${currentLimit}`);
|
||
if (!response.ok) throw new Error('Could not load match history.');
|
||
const body = await response.json();
|
||
if (server !== currentServer || limit !== currentLimit) return;
|
||
matches = body.matches;
|
||
total = body.total;
|
||
error = '';
|
||
} catch (cause) {
|
||
if (server === currentServer && limit === currentLimit) error = (cause as Error).message;
|
||
} finally {
|
||
if (server === currentServer && limit === currentLimit) loading = false;
|
||
}
|
||
}
|
||
$effect(() => {
|
||
const currentServer = server;
|
||
const currentLimit = limit;
|
||
return pollVisible(() => refresh(currentServer, currentLimit), 10000);
|
||
});
|
||
</script>
|
||
|
||
<Card padding="lg">
|
||
<h2>Match history</h2>
|
||
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||
{#if error}
|
||
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
|
||
{/if}
|
||
{#if loading}
|
||
<EmptyState title="Loading matches…" />
|
||
{:else if matches.length === 0 && !error}
|
||
<EmptyState title="No matches recorded yet" description={server === undefined ? 'Matches appear here when servers report gameplay.' : 'Matches appear here when this server reports gameplay.'} />
|
||
{:else if matches.length}
|
||
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<div class="table-scroll" tabindex="0" role="region" aria-label="Match history">
|
||
<table>
|
||
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
|
||
<tbody>
|
||
{#each matches as match (match.id)}
|
||
<tr class:completed={match.status === 'completed'}>
|
||
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
|
||
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
|
||
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
|
||
<td>
|
||
{#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}<small>View match →</small>
|
||
</a>
|
||
{:else}{match.map}{/if}
|
||
</td>
|
||
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
|
||
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
|
||
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
|
||
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
|
||
{/if}
|
||
</Card>
|
||
|
||
<style>
|
||
h2 { margin: 0; font-size: var(--text-md); }
|
||
p { color: var(--text-secondary); font-size: var(--text-sm); }
|
||
.table-scroll { overflow-x: auto; }
|
||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||
th { color: var(--text-secondary); font-size: var(--text-xs); }
|
||
td { font-variant-numeric: tabular-nums; }
|
||
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
|
||
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
|
||
.match-link:hover { text-decoration: underline; }
|
||
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); }
|
||
</style>
|