Files
electric-cherry/src/lib/ui/components/ServerCard.svelte
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
2026-09-19 21:34:34 -04:00

342 lines
11 KiB
Svelte

<script lang="ts">
import RoundTelemetry from './RoundTelemetry.svelte';
import ServerIdentity from './ServerIdentity.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 { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.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;
compactEmpty?: boolean;
mapLabelOverride?: string;
}
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,
compactEmpty = false,
mapLabelOverride
}: Props = $props();
function ago(iso: string | null, now: number): string {
if (!iso) return 'never';
const d = 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 === 'online' || health === 'stale');
let title = $derived(serverTitle(name));
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).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 === 'online' && (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 {title}, {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"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
</div>
{#if health === 'starting'}
<Badge variant="info" uppercase>Starting</Badge>
{:else if health === 'offline'}
<Badge variant="danger" uppercase>offline</Badge>
{:else if health === 'stale'}
<Badge variant="warning">Stale</Badge>
{:else}
<div class="players" aria-live="polite">
<Stat
value={players ?? '—'}
size="md"
tone={players !== null && players > 0 ? 'online' : 'primary'}
tween
/>
</div>
{/if}
</div>
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
{#if health === 'stale'}
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
{/if}
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
{#if showTiming && !(compactEmpty && players === 0)}
<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">{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}</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, $uiNow)}</span>
{:else if lastRestartedAt}
<span>Restart {ago(lastRestartedAt, $uiNow)}</span>
{:else}
<span class="dim">No data</span>
{/if}
</div>
</Card>
<style>
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
/* 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) {
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
}
.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);
min-width: 0;
}
.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;
}
.gt { color: var(--text-tertiary); }
.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: var(--text-xs);
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>