693 lines
28 KiB
Svelte
693 lines
28 KiB
Svelte
<script lang="ts">
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import { untrack } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { browser } from '$app/environment';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import { timestampISO } from '$lib/time-zone.js';
|
||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||
import { goto } from '$app/navigation';
|
||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||
import { uiNow } from '$ui/stores/servers.js';
|
||
import type { MatchHistoryResponse } from '$lib/match-history.js';
|
||
import {
|
||
matchApiParams,
|
||
matchHistoryHref,
|
||
matchPageSize,
|
||
readMatchQuery,
|
||
type MatchHistoryQuery,
|
||
type MatchSort
|
||
} from '$lib/match-history-query.js';
|
||
import { matchInstanceLabel } from '$ui/match-history.js';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
import { elapsedSince, 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));
|
||
let loading = $state(!untrack(() => initial));
|
||
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;
|
||
const now = $derived(browser ? $uiNow : Date.now());
|
||
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
|
||
const apiUrl = $derived.by(() => {
|
||
const params = matchApiParams($page.url);
|
||
params.set('limit', String(matchPageSize));
|
||
const endpoint =
|
||
server === undefined
|
||
? '/api/matches'
|
||
: `/api/servers/${encodeURIComponent(server)}/matches`;
|
||
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 = '';
|
||
checkedAt = Date.now();
|
||
});
|
||
});
|
||
$effect(() => {
|
||
const url = apiUrl;
|
||
void retry;
|
||
untrack(() => {
|
||
if (loadedUrl !== url) loading = true;
|
||
error = '';
|
||
});
|
||
return pollVisible(async (signal) => {
|
||
try {
|
||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||
if (!response.ok) throw new Error('Could not load match history.');
|
||
const body: MatchHistoryResponse = await response.json();
|
||
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 = '';
|
||
checkedAt = Date.now();
|
||
} catch (cause) {
|
||
if (signal.aborted || url !== apiUrl) return;
|
||
error = cause instanceof Error ? cause.message : 'Could not load match history.';
|
||
loading = false;
|
||
return false;
|
||
}
|
||
}, 10000);
|
||
});
|
||
function update(changes: Partial<Record<keyof MatchHistoryQuery, string>>) {
|
||
const href = matchHistoryHref(pendingUrl ?? $page.url, changes);
|
||
pendingUrl = new URL(href, $page.url);
|
||
const id = ++navigationId;
|
||
void goto(href, { noScroll: true, keepFocus: true })
|
||
.catch(() => {
|
||
error = 'Could not update the match filters. Please try again.';
|
||
})
|
||
.finally(() => {
|
||
if (id === navigationId) pendingUrl = undefined;
|
||
});
|
||
}
|
||
function sort(key: MatchSort) {
|
||
update({
|
||
sort: key,
|
||
direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc'
|
||
});
|
||
}
|
||
function reset() {
|
||
update({ game: '', map: '', q: '', days: '', status: '' });
|
||
}
|
||
</script>
|
||
|
||
<section class="history" aria-label="Match history">
|
||
{#snippet filterFields(suffix: string)}
|
||
<div class="filters">
|
||
{#if server === undefined}<Field forId={`match-game${suffix}`} label="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
|
||
></Field
|
||
>{/if}
|
||
<Field forId={`match-map${suffix}`} label="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></Field
|
||
>
|
||
{#if server === undefined}<form
|
||
role="search"
|
||
onsubmit={(event) => {
|
||
event.preventDefault();
|
||
update({ q: search.trim() });
|
||
if (suffix) filtersOpen = false;
|
||
}}
|
||
>
|
||
<Field forId={`match-search${suffix}`} label="Server">
|
||
<Input
|
||
id={`match-search${suffix}`}
|
||
type="search"
|
||
bind:value={search}
|
||
maxlength={100}
|
||
placeholder="Find a server…"
|
||
aria-describedby={`match-search-help${suffix}`}
|
||
/></Field
|
||
>
|
||
<span id={`match-search-help${suffix}`} class="sr-only"
|
||
>Press Enter to search by server name.</span
|
||
>
|
||
</form>{/if}
|
||
<Field forId={`match-days${suffix}`} label="Date range">
|
||
<Select
|
||
id={`match-days${suffix}`}
|
||
value={String(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
|
||
></Field
|
||
>
|
||
<Field forId={`match-status${suffix}`} label="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
|
||
><option value="end-report-missing">End report missing</option></Select
|
||
></Field
|
||
>
|
||
{#if suffix}
|
||
<Field forId={`match-sort${suffix}`} label="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
|
||
></Field
|
||
>
|
||
<Field forId={`match-order${suffix}`} label="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
|
||
></Field
|
||
>
|
||
{/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."
|
||
/>
|
||
{:else if data?.matches.length}
|
||
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<div
|
||
class="table-scroll"
|
||
bind:this={tableRegion}
|
||
tabindex="0"
|
||
role="region"
|
||
aria-label="Recorded matches"
|
||
>
|
||
<table>
|
||
<caption class="sr-only"
|
||
>Matches sorted by {sorts
|
||
.find((item) => item.key === filters.sort)
|
||
?.label.toLowerCase()}, {filters.direction === 'desc'
|
||
? 'highest or newest'
|
||
: 'lowest or oldest'} first</caption
|
||
>
|
||
<thead
|
||
><tr>
|
||
<th
|
||
scope="col"
|
||
aria-sort={filters.sort === 'started'
|
||
? filters.direction === 'desc'
|
||
? 'descending'
|
||
: 'ascending'
|
||
: 'none'}
|
||
><button onclick={() => sort('started')}
|
||
>Started{filters.sort === 'started'
|
||
? filters.direction === 'desc'
|
||
? ' ↓'
|
||
: ' ↑'
|
||
: ''}</button
|
||
></th
|
||
>
|
||
<th scope="col">Match</th><th scope="col">Status</th>
|
||
{#each sorts.filter((item) => item.key !== 'started') as item}
|
||
<th
|
||
scope="col"
|
||
class="numeric"
|
||
aria-sort={filters.sort === item.key
|
||
? filters.direction === 'desc'
|
||
? 'descending'
|
||
: 'ascending'
|
||
: 'none'}
|
||
><button
|
||
onclick={() => sort(item.key)}
|
||
title={item.key === 'players'
|
||
? 'Unique players observed throughout the match'
|
||
: item.key === 'round'
|
||
? 'Final round, or highest observed round for unfinished matches'
|
||
: 'Recorded duration; in-progress and unknown durations sort last'}
|
||
aria-describedby={item.key === 'players'
|
||
? 'match-player-help'
|
||
: undefined}
|
||
>{item.label}{filters.sort === item.key
|
||
? filters.direction === 'desc'
|
||
? ' ↓'
|
||
: ' ↑'
|
||
: ''}</button
|
||
></th
|
||
>
|
||
{/each}
|
||
</tr></thead
|
||
>
|
||
<tbody
|
||
>{#each data.matches as match (match.id)}
|
||
{@const reporting =
|
||
match.status === 'active' &&
|
||
!match.server_deleted_at &&
|
||
freshStatusTimestamp(timestampISO(match.lastReportedAt) ?? null, now)}
|
||
{@const notReporting = match.status === 'active' && !reporting}
|
||
{@const status = notReporting
|
||
? 'Not reporting'
|
||
: match.status === 'active'
|
||
? 'In progress'
|
||
: match.status === 'completed'
|
||
? 'Completed'
|
||
: match.status === 'end-report-missing'
|
||
? 'End report missing'
|
||
: 'Interrupted'}
|
||
{@const duration =
|
||
match.status === 'active'
|
||
? reporting
|
||
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
|
||
: null
|
||
: match.durationMs === null
|
||
? null
|
||
: formatDuration(match.durationMs)}
|
||
<tr>
|
||
<td><RelativeTime value={match.startedAt} /></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 ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
|
||
>{match.map} · Match #{match.id}</a
|
||
>
|
||
{:else if reporting}<a
|
||
class="match-link"
|
||
href={contextHref(
|
||
`/servers/${encodeURIComponent(match.server)}`,
|
||
$page.url
|
||
)}
|
||
aria-label={`View live ${match.map} match on ${matchInstanceLabel(match)}`}
|
||
>{match.map}</a
|
||
>
|
||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||
<small class="mobile-match-context"
|
||
><RelativeTime value={match.startedAt} /> · {status}{duration !==
|
||
null
|
||
? ` · ${duration}`
|
||
: ''}</small
|
||
>
|
||
{#if notReporting}<small class="mobile-match-context"
|
||
>Last report <RelativeTime
|
||
value={match.lastReportedAt}
|
||
missing="Not recorded"
|
||
/></small
|
||
>{/if}
|
||
{#if match.missingReports !== null && match.missingReports > 0}<small
|
||
class="mobile-match-context"
|
||
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||
>{/if}
|
||
{#if server === undefined}<div class="instance">
|
||
<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={reporting}
|
||
class:interrupted={match.status === 'interrupted' ||
|
||
match.status === 'end-report-missing' ||
|
||
notReporting}>{status}</span
|
||
>{#if notReporting}<small
|
||
>Last report <RelativeTime
|
||
value={match.lastReportedAt}
|
||
missing="Not recorded"
|
||
/></small
|
||
>{/if}{#if match.missingReports !== null && match.missingReports > 0}<small
|
||
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||
>{/if}</td
|
||
>
|
||
<td class="numeric">{match.uniquePlayers}</td>
|
||
<td class="numeric"
|
||
><span
|
||
title={duration === null
|
||
? 'Duration was not recorded.'
|
||
: undefined}>{duration ?? '—'}</span
|
||
></td
|
||
>
|
||
<td class="numeric"
|
||
>{match.status === 'completed'
|
||
? (match.finalRound ?? '—')
|
||
: (match.observedRound ??
|
||
'—')}{#if match.status !== 'completed'}<small
|
||
>Highest observed</small
|
||
>{/if}</td
|
||
>
|
||
</tr>
|
||
{/each}</tbody
|
||
>
|
||
</table>
|
||
</div>
|
||
<div class="pagination">
|
||
<span
|
||
>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit +
|
||
data.matches.length} of {data.total} matches</span
|
||
>
|
||
{#if data.total > data.limit}<div>
|
||
<Button
|
||
size="sm"
|
||
disabled={data.page <= 1}
|
||
href={matchHistoryHref($page.url, { page: String(data.page - 1) })}
|
||
>Previous</Button
|
||
><Button
|
||
size="sm"
|
||
disabled={data.page * data.limit >= data.total}
|
||
href={matchHistoryHref($page.url, { page: String(data.page + 1) })}
|
||
>Next</Button
|
||
>
|
||
</div>{/if}
|
||
</div>
|
||
{:else if !error}<EmptyState
|
||
title={hasFilters ? 'No matches found' : 'No matches recorded yet'}
|
||
description={hasFilters
|
||
? 'Try another game, map, instance, or date range.'
|
||
: 'Matches appear when instances report gameplay.'}
|
||
>
|
||
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}
|
||
>Clear filters</Button
|
||
>{/if}{/snippet}
|
||
</EmptyState>{/if}
|
||
<span id="match-player-help" class="sr-only"
|
||
>Players counts unique players observed throughout the match.</span
|
||
>
|
||
<details class="about">
|
||
<summary>About match history</summary>
|
||
<p>
|
||
Players counts unique players across all reports. A dash means the value is unavailable,
|
||
not zero. In-progress matches show elapsed time since their recorded start and link to
|
||
the live server while reports are current. Otherwise, they show Not reporting with the
|
||
last report time. Completed durations require a recorded start and completed ending. End
|
||
report missing means a newer game was reported before this game's end report arrived.
|
||
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
|
||
reports arrive. Only completed games contribute to the leaderboard. Round shows the
|
||
final round for completed matches and the highest observed round for unfinished matches.
|
||
Open a completed match for exact timestamps and starting and ending player counts.
|
||
</p>
|
||
</details>
|
||
</section>
|
||
|
||
<style>
|
||
.history {
|
||
display: grid;
|
||
gap: var(--space-4);
|
||
min-width: 0;
|
||
}
|
||
.filters {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-3);
|
||
}
|
||
form {
|
||
flex: 1;
|
||
min-width: 145px;
|
||
}
|
||
.filters :global(.field) {
|
||
flex: 1;
|
||
min-width: 145px;
|
||
}
|
||
|
||
.filters :global(.field) {
|
||
flex-direction: column;
|
||
}
|
||
|
||
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 {
|
||
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;
|
||
}
|
||
th {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
background: var(--surface-sunken);
|
||
}
|
||
th button,
|
||
.text-button {
|
||
border: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
padding: 4px 0;
|
||
}
|
||
.text-button {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
td {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
tbody tr:hover,
|
||
tbody tr:focus-within {
|
||
background: var(--surface-raised);
|
||
}
|
||
.numeric {
|
||
text-align: right;
|
||
}
|
||
.match {
|
||
min-width: 200px;
|
||
white-space: normal;
|
||
}
|
||
.match-link {
|
||
color: var(--accent-primary-hover);
|
||
text-decoration: none;
|
||
}
|
||
.match-link,
|
||
.match-name {
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
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);
|
||
}
|
||
.status {
|
||
color: var(--text-secondary);
|
||
}
|
||
.status.active {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.status.interrupted {
|
||
color: var(--state-warning);
|
||
}
|
||
.about {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
.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;
|
||
}
|
||
.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 > form {
|
||
min-width: 0;
|
||
}
|
||
.filters > :global(.field) {
|
||
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>
|