331 lines
9.8 KiB
Svelte
331 lines
9.8 KiB
Svelte
<script lang="ts">
|
|
import RoundTelemetry from './RoundTelemetry.svelte';
|
|
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
|
import { uiNow } from '$ui/stores/servers.js';
|
|
import { gametypeLabel } from '$ui/gametype.js';
|
|
import { elapsedSince } from '$ui/duration.js';
|
|
import Card from './primitives/Card.svelte';
|
|
import Badge from './primitives/Badge.svelte';
|
|
import Stat from './primitives/Stat.svelte';
|
|
import StatusPip from './primitives/StatusPip.svelte';
|
|
|
|
interface Held {
|
|
target: string;
|
|
betaId: number;
|
|
}
|
|
interface Props {
|
|
name: string;
|
|
players?: number | null;
|
|
map?: string | null;
|
|
gametype?: string | null;
|
|
roster?: RosterPlayer[] | null;
|
|
health?: Health;
|
|
lastSeenAt?: string | null;
|
|
lastError?: string | null;
|
|
held?: Held[];
|
|
lastRestartedAt?: string | null;
|
|
restarting?: boolean;
|
|
activeSince?: string | null;
|
|
inactiveSince?: string | null;
|
|
gameStartedAt?: string | null;
|
|
gameEndedAt?: string | null;
|
|
}
|
|
let {
|
|
name,
|
|
players = null,
|
|
map = null,
|
|
gametype = null,
|
|
roster = null,
|
|
health = 'offline',
|
|
lastSeenAt = null,
|
|
lastError = null,
|
|
held = [],
|
|
lastRestartedAt = null,
|
|
restarting = false,
|
|
activeSince = null,
|
|
inactiveSince = null,
|
|
gameStartedAt = null,
|
|
gameEndedAt = null
|
|
}: Props = $props();
|
|
|
|
function ago(iso: string | null): string {
|
|
if (!iso) return 'never';
|
|
const d = Date.now() - new Date(iso).getTime();
|
|
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
|
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
|
return `${Math.floor(d / 86_400_000)}d ago`;
|
|
}
|
|
|
|
let topRoster = $derived(
|
|
(roster ?? [])
|
|
.slice()
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 3)
|
|
);
|
|
let showRoster = $derived(health !== 'offline');
|
|
let mapLabel = $derived(map ?? '—');
|
|
let gtLabel = $derived(gametypeLabel(gametype));
|
|
|
|
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
|
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
|
|
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
|
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
|
let showTiming = $derived(
|
|
health !== 'offline' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
|
);
|
|
|
|
// Shared-element transition to /servers/{name} — Chromium only, others
|
|
// fall through to a plain nav.
|
|
let cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
|
</script>
|
|
|
|
<Card
|
|
href="/servers/{name}"
|
|
elevation="interactive"
|
|
padding="md"
|
|
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
|
|
ariaLabel="Server {name}, {health}"
|
|
style={cardStyle}
|
|
>
|
|
{#if restarting}
|
|
<span class="restart-bar" aria-hidden="true"></span>
|
|
{/if}
|
|
|
|
<div class="head">
|
|
<div class="name-row">
|
|
<StatusPip {health} />
|
|
<strong class="name">{name}</strong>
|
|
</div>
|
|
{#if health === 'offline'}
|
|
<Badge variant="danger" uppercase>offline</Badge>
|
|
{:else}
|
|
<div class="players" aria-live="polite">
|
|
<Stat
|
|
value={players ?? 0}
|
|
size="md"
|
|
tone={players !== null && players > 0 ? 'online' : 'primary'}
|
|
tween
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="meta">
|
|
<span class="map">{mapLabel}</span>
|
|
{#if gtLabel}
|
|
<span class="dot" aria-hidden="true">·</span>
|
|
<span class="gt">{gtLabel}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<RoundTelemetry {name} />
|
|
|
|
{#if showTiming}
|
|
<div class="timing">
|
|
{#if activeElapsed}
|
|
<div class="metric">
|
|
<span class="metric-label">Active</span>
|
|
<span class="metric-value">{activeElapsed}</span>
|
|
</div>
|
|
{:else if inactiveElapsed}
|
|
<div class="metric">
|
|
<span class="metric-label">Inactive</span>
|
|
<span class="metric-value">{inactiveElapsed}</span>
|
|
</div>
|
|
{/if}
|
|
{#if gameElapsed}
|
|
<div class="metric">
|
|
<span class="metric-label">Match</span>
|
|
<span class="metric-value">{gameElapsed}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showRoster}
|
|
{#if topRoster.length > 0}
|
|
<ul class="roster">
|
|
{#each topRoster as p (p.guid ?? p.name)}
|
|
<li>
|
|
<span class="pname">{p.name || '—'}</span>
|
|
<span class="pscore">{p.score}</span>
|
|
<span class="pping">{p.ping}ms</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<div class="lobby-empty">Lobby is empty</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if held.length > 0}
|
|
<div class="held">
|
|
{#each held as h (h.target)}
|
|
<Badge variant="info" size="sm" title="held by beta {h.betaId}">β {h.target}</Badge>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="foot">
|
|
{#if health === 'offline' && lastError}
|
|
<span class="err">{lastError}</span>
|
|
{:else if lastSeenAt}
|
|
<span>Last seen {ago(lastSeenAt)}</span>
|
|
{:else if lastRestartedAt}
|
|
<span>Restart {ago(lastRestartedAt)}</span>
|
|
{:else}
|
|
<span class="dim">No data</span>
|
|
{/if}
|
|
</div>
|
|
</Card>
|
|
|
|
<style>
|
|
/* Global tweaks scoped through :global() since Card is a child component. */
|
|
:global(.card.restarting) {
|
|
outline: 1px solid rgba(255, 176, 32, 0.28);
|
|
}
|
|
:global(.card.offline) {
|
|
opacity: 0.62;
|
|
}
|
|
|
|
.restart-bar {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0;
|
|
height: 2px;
|
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
background: linear-gradient(
|
|
90deg,
|
|
transparent,
|
|
var(--state-warning),
|
|
var(--state-online),
|
|
transparent
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: spark-slide 1.6s var(--ease-linear) infinite;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
.name-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
min-width: 0;
|
|
}
|
|
.name {
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-md);
|
|
font-weight: var(--weight-semibold);
|
|
color: var(--text-primary);
|
|
letter-spacing: var(--tracking-tight);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.players { flex-shrink: 0; }
|
|
|
|
.meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-top: var(--space-1);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
.gt { color: var(--text-tertiary); }
|
|
.dot { color: var(--text-quaternary); }
|
|
|
|
.timing {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-5);
|
|
margin-top: var(--space-2);
|
|
}
|
|
.metric {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.metric-label {
|
|
font-size: 10px;
|
|
font-weight: var(--weight-semibold);
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--tracking-label);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.metric-value {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.roster {
|
|
list-style: none;
|
|
padding: var(--space-3) 0 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
.roster li {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto;
|
|
gap: var(--space-3);
|
|
align-items: center;
|
|
padding: 2px 0;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.pname {
|
|
color: var(--text-primary);
|
|
font-weight: var(--weight-regular);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.pscore,
|
|
.pping {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.pping { min-width: 44px; text-align: right; }
|
|
|
|
.lobby-empty {
|
|
padding-top: var(--space-3);
|
|
border-top: 1px solid var(--border-subtle);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.held {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.foot {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: auto;
|
|
padding-top: var(--space-2);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.err { color: var(--state-danger); }
|
|
.dim { color: var(--text-quaternary); }
|
|
</style>
|