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.
This commit is contained in:
+40
-9
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { afterNavigate, replaceState } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -7,7 +9,7 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
||||
import { fleet, fleetServers, fleetError, fleetRefreshing, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
||||
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
||||
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
||||
@@ -25,6 +27,7 @@
|
||||
let viewOptionsOpen = $state(false);
|
||||
let pinned = $state<string[]>([]);
|
||||
let preferencesReady = $state(false);
|
||||
let routerReady = $state(false);
|
||||
let deployOpen = $state(false);
|
||||
let initialGame = $state<ZombieGame>('t5');
|
||||
let initialMapSlug = $state('');
|
||||
@@ -33,6 +36,17 @@
|
||||
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||
];
|
||||
|
||||
function readViewUrl() {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
query = params.get('q') ?? '';
|
||||
if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view;
|
||||
if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping;
|
||||
if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort;
|
||||
if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter;
|
||||
if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!;
|
||||
}
|
||||
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
|
||||
@@ -43,6 +57,7 @@
|
||||
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
|
||||
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
|
||||
} catch { /* Preferences also work when storage is disabled. */ }
|
||||
readViewUrl();
|
||||
preferencesReady = true;
|
||||
void refreshFleet();
|
||||
});
|
||||
@@ -51,7 +66,15 @@
|
||||
if (!preferencesReady) return;
|
||||
try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); }
|
||||
catch { /* The current view still works without persistent storage. */ }
|
||||
const values = { q: query, view, group: grouping, sort, filter, game };
|
||||
if (!routerReady) return;
|
||||
untrack(() => {
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
||||
if (url.href !== window.location.href) replaceState(url, $page.state);
|
||||
});
|
||||
});
|
||||
const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - $fleet.receivedAt) / 1000)); });
|
||||
|
||||
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
|
||||
? { ...server, health: 'stale' as const } : server));
|
||||
@@ -127,12 +150,16 @@
|
||||
{#if !$fleet.receivedAt}
|
||||
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
||||
{:else}
|
||||
<div class="overview" aria-label="Fleet summary">
|
||||
{#if servers.length}<div class="overview" aria-label="Fleet summary">
|
||||
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
||||
<a class="metric-link" href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||
</div>
|
||||
<a class="metric-link" class:no-rollouts={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
||||
</div>{/if}
|
||||
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
|
||||
{/if}
|
||||
|
||||
{#if problems.length || attentionOperations.length || starting}
|
||||
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
||||
@@ -174,7 +201,7 @@
|
||||
{/if}
|
||||
|
||||
{#if servers.length === 0}
|
||||
<EmptyState title="Your first lobby starts here" description="Deploy a server or choose a saved blueprint to bring your fleet online." />
|
||||
<EmptyState title="Your first server starts here" description="Check your connections and saved keys, then create a server or use a blueprint.">{#snippet actions()}<Button href="/settings">Check system setup</Button><Button href="/deployments?tab=blueprints" variant="ghost">Browse blueprints</Button>{/snippet}</EmptyState>
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
@@ -199,9 +226,9 @@
|
||||
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
|
||||
{#each groups as [key, group] (key)}
|
||||
<section class="map-group" aria-label={group.title}>
|
||||
<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
||||
</div>
|
||||
</div>{/if}
|
||||
{#if view === 'cards'}
|
||||
<div class="cards">{#each group.servers as server (server.name)}
|
||||
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
|
||||
@@ -252,6 +279,10 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.setup-note strong { color: var(--text-primary); }
|
||||
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-rollouts { display: none; } }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
@@ -283,7 +314,7 @@
|
||||
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
|
||||
.search { flex: 1; min-width: min(280px, 100%); }
|
||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||
input::placeholder { color: var(--text-secondary); }
|
||||
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user