Add sidebar navigation and global command search
This commit is contained in:
+84
-17
@@ -7,6 +7,7 @@
|
||||
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
|
||||
import { page } from '$app/stores';
|
||||
import Navigation from '$ui/components/Navigation.svelte';
|
||||
import CommandPalette from '$ui/components/CommandPalette.svelte';
|
||||
import { visibleNavigation } from '$lib/navigation.js';
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
@@ -27,6 +28,9 @@
|
||||
let signingOut = $state(false);
|
||||
let signOutError = $state('');
|
||||
let helpOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let drawerOpen = $state(false);
|
||||
let sidebarCollapsed = $state(false);
|
||||
let accountButton = $state<HTMLButtonElement>();
|
||||
const accountName = $derived(data.user?.displayName || data.user?.username || 'Account');
|
||||
const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?');
|
||||
@@ -36,12 +40,14 @@
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
if (accountOpen) accountButton?.focus();
|
||||
accountOpen = false;
|
||||
helpOpen = true;
|
||||
}
|
||||
|
||||
beforeNavigate(() => performance.mark('ec:navigation-start'));
|
||||
afterNavigate(() => { if (performance.getEntriesByName('ec:navigation-start').length) { performance.measure('ec:navigation', 'ec:navigation-start'); performance.clearMarks('ec:navigation-start'); } });
|
||||
afterNavigate(() => { accountOpen = false; resolveConfirmation(false); });
|
||||
afterNavigate(() => { accountOpen = false; drawerOpen = false; paletteOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
|
||||
@@ -66,14 +72,20 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!sessionReady) return;
|
||||
const path = $page.url.pathname;
|
||||
if (!(path === '/' || /^\/(scripts|configs|plugins)\//.test(path) || path === '/betas/new')) return;
|
||||
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot) hydrateFleet($page.data.fleetSnapshot);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!sessionReady) return;
|
||||
// Navigation badges need fresh host evidence on every signed-in page.
|
||||
return startFleetMonitor();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const stopTimeZone = startTimeZonePreference();
|
||||
const desktop = window.matchMedia('(min-width: 961px)');
|
||||
const closeDrawer = () => { if (desktop.matches) drawerOpen = false; };
|
||||
desktop.addEventListener('change', closeDrawer);
|
||||
|
||||
const shortcuts = Object.fromEntries(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut).map(item => [item.shortcut!, item.href]));
|
||||
let armed = false;
|
||||
@@ -81,6 +93,13 @@
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!sessionReady) return;
|
||||
// Global search also works while focus is in a page's search field.
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
accountOpen = false; drawerOpen = false; helpOpen = false;
|
||||
paletteOpen = !paletteOpen;
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
@@ -106,6 +125,7 @@
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopTimeZone();
|
||||
desktop.removeEventListener('change', closeDrawer);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (armTimer) clearTimeout(armTimer);
|
||||
};
|
||||
@@ -120,17 +140,40 @@
|
||||
|
||||
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
|
||||
|
||||
<div class="shell">
|
||||
<div class="shell" class:with-sidebar={sessionReady} class:sidebar-collapsed={sidebarCollapsed}>
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
{#if sessionReady && data.user}
|
||||
<aside class="sidebar" id="desktop-navigation" hidden={sidebarCollapsed}>
|
||||
<a class="sidebar-brand" href="/" aria-label="Electric Cherry home"><BrandMark size="sm" /></a>
|
||||
<div class="sidebar-scroll"><Navigation role={data.user.role} /></div>
|
||||
</aside>
|
||||
<div class="navigation-drawer">
|
||||
<Dialog bind:open={drawerOpen} title="Navigation" drawer>
|
||||
<div id="mobile-navigation"><Navigation role={data.user.role} onNavigate={() => drawerOpen = false} /></div>
|
||||
</Dialog>
|
||||
</div>
|
||||
{/if}
|
||||
{#if publicPage}
|
||||
<header class="auth-brand"><BrandMark size="md" /></header>
|
||||
{:else}
|
||||
<header class="app-header">
|
||||
{#if sessionReady}
|
||||
<button class="icon-button desktop-toggle" type="button" aria-label={sidebarCollapsed ? 'Expand navigation' : 'Collapse navigation'} aria-controls="desktop-navigation" aria-expanded={!sidebarCollapsed} onclick={() => sidebarCollapsed = !sidebarCollapsed}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M7 3v14" /></svg>
|
||||
</button>
|
||||
<button class="icon-button mobile-toggle" type="button" aria-label="Open navigation" aria-controls="mobile-navigation" aria-expanded={drawerOpen} onclick={() => drawerOpen = true}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14" /></svg>
|
||||
</button>
|
||||
{/if}
|
||||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
</a>
|
||||
{#if sessionReady && data.user}<div class="primary-navigation"><Navigation role={data.user.role} /></div>{/if}
|
||||
{#if sessionReady}<button class="search-button" type="button" aria-label="Search pages, servers, hosts, and players" aria-keyshortcuts="Meta+k Control+k" onclick={() => paletteOpen = true}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8" cy="8" r="5" /><path d="m12 12 5 5" /></svg>
|
||||
<span class="search-label">Search pages, servers, players…</span><kbd>⌘K / Ctrl K</kbd>
|
||||
</button>{/if}
|
||||
<div class="header-actions">
|
||||
{#if sessionReady}<button class="icon-button help-button" type="button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={openHelp}>?</button>{/if}
|
||||
{#if data.user}
|
||||
<div class="account-menu">
|
||||
<button bind:this={accountButton} class="account-button" type="button"
|
||||
@@ -143,6 +186,7 @@
|
||||
<div class="account-links" id="account-links">
|
||||
<div class="account-name">{accountName}</div>
|
||||
<a href="/account">My account</a>
|
||||
{#if sessionReady}<button onclick={openHelp}>Keyboard shortcuts <span aria-hidden="true">?</span></button>{/if}
|
||||
{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}
|
||||
<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>
|
||||
{#if signOutError}<p role="alert">{signOutError}</p>{/if}
|
||||
@@ -154,13 +198,13 @@
|
||||
</header>
|
||||
{/if}
|
||||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||||
{#if sessionReady && data.user}<Navigation role={data.user.role} variant="secondary" />{/if}
|
||||
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
|
||||
{@render children()}
|
||||
</main>
|
||||
<Confirmation />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
|
||||
{#if sessionReady && data.user}<CommandPalette bind:open={paletteOpen} role={data.user.role} />{/if}
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then a destination key. Page shortcuts are inactive while typing or using a dialog; search works from any field.">
|
||||
<dl class="shortcuts"><div><dt><kbd>⌘K / Ctrl K</kbd></dt><dd>Search pages, servers, hosts, and players</dd></div>{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -204,14 +248,29 @@
|
||||
}
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.shell.with-sidebar { padding-left: 224px; }
|
||||
.shell.sidebar-collapsed { padding-left: 0; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 25; display: flex; flex-direction: column; width: 224px; border-right: 1px solid var(--border-subtle); background: var(--surface); }
|
||||
.sidebar[hidden] { display: none; }
|
||||
.sidebar-brand { display: flex; align-items: center; min-height: 60px; padding: 0 var(--space-4); text-decoration: none; }
|
||||
.sidebar-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-2) var(--space-6); scrollbar-width: thin; }
|
||||
.navigation-drawer :global(dialog.drawer) { inset: 0 auto 0 0; width: min(320px, 100vw); border-left: 0; border-right: 1px solid var(--border-subtle); }
|
||||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
||||
main { min-width:0; overflow-wrap:anywhere; padding:var(--space-6) var(--space-8) var(--space-16); max-width:1600px; margin:0 auto; view-transition-name:main; }
|
||||
main.auth-main { max-width:520px; padding-top:16px; }
|
||||
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); }
|
||||
.viewer-note strong { color:var(--text-primary); }
|
||||
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:24px; min-height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
|
||||
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:var(--space-3); min-height:60px; padding:0 var(--space-5); border-bottom:1px solid var(--border-subtle); background:var(--surface-sunken); }
|
||||
.brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; }
|
||||
.primary-navigation { flex:1; min-width:0; }
|
||||
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: none; }
|
||||
.icon-button { display: inline-grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: var(--text-lg); cursor: pointer; }
|
||||
.icon-button:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.icon-button svg, .search-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; flex-shrink: 0; }
|
||||
.mobile-toggle { display: none; }
|
||||
.search-button { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex: 1; max-width: 440px; height: 36px; padding: 0 var(--space-3); border: 1px solid var(--border-control); border-radius: var(--radius-sm); background: var(--surface-sunken); color: var(--text-secondary); cursor: pointer; text-align: left; font-size: var(--text-xs); }
|
||||
.search-button:hover { border-color: var(--border-control-hover); color: var(--text-primary); }
|
||||
.search-label { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.search-button kbd { padding: 1px 4px; white-space: nowrap; }
|
||||
.header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||||
.account-menu { position:relative; }
|
||||
.account-button { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
|
||||
@@ -223,7 +282,7 @@
|
||||
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||||
.shortcuts { display:grid; gap:12px; margin:0; }
|
||||
.shortcuts > div { display:flex; gap:20px; align-items:center; }
|
||||
.shortcuts dt { min-width:55px; }
|
||||
.shortcuts dt { min-width:120px; }
|
||||
.shortcuts dd { margin:0; font-size:var(--text-sm); }
|
||||
kbd { font-family:var(--font-mono); padding:3px 8px; border:1px solid var(--border-strong); border-radius:5px; }
|
||||
.skip-link { position:fixed; left:16px; top:-80px; z-index:300; padding:12px 16px; background:var(--surface-raised); border-radius:var(--radius-md); }
|
||||
@@ -231,17 +290,25 @@
|
||||
:global(input),:global(select),:global(textarea) { min-width:0; max-width:100%; }
|
||||
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
|
||||
:global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
|
||||
@media (max-width:999px) {
|
||||
.app-header { flex-wrap:wrap; gap:0 16px; padding-top:8px; }
|
||||
.primary-navigation { order:3; flex-basis:100%; }
|
||||
@media (max-width:960px) {
|
||||
.shell.with-sidebar { padding-left: 0; }
|
||||
.sidebar, .desktop-toggle { display: none; }
|
||||
.mobile-toggle { display: inline-grid; }
|
||||
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: flex; }
|
||||
.app-header { min-height: 56px; gap: var(--space-2); padding: 0 var(--space-3); }
|
||||
.search-button { margin-left: auto; max-width: 300px; }
|
||||
.search-button kbd { display: none; }
|
||||
.header-actions { margin-left: 0; }
|
||||
}
|
||||
@media (max-width:720px) {
|
||||
.app-header { padding-inline:12px; gap:8px; }
|
||||
.app-header { padding-inline: var(--space-2); gap: var(--space-1); }
|
||||
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
|
||||
main { padding:var(--space-5) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
|
||||
.search-button { flex: 0 0 44px; width: 44px; height: 44px; padding: 0; justify-content: center; border: 0; }
|
||||
.search-label, .help-button { display: none; }
|
||||
main { padding:var(--space-4) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
|
||||
:global(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size:16px !important; min-height:44px; }
|
||||
:global(button),:global(.btn) { min-height:44px; }
|
||||
}
|
||||
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { font-size:10px; letter-spacing:0; } .header-actions { gap:4px; } }
|
||||
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { display: none; } .brand-link::after { content: 'EC'; margin-left: var(--space-2); color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); } .header-actions { gap:4px; } }
|
||||
:global(::view-transition-old(root)),:global(::view-transition-new(root)),:global(::view-transition-old(main)),:global(::view-transition-new(main)) { animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); }
|
||||
</style>
|
||||
|
||||
+31
-12
@@ -45,6 +45,8 @@
|
||||
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||
];
|
||||
|
||||
$effect(() => { if (canOperate && $page.url.searchParams.get('action') === 'create') deployOpen = true; });
|
||||
|
||||
function readViewUrl() {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
query = params.get('q') ?? '';
|
||||
@@ -160,11 +162,11 @@
|
||||
function resetFilters() { query = ''; filter = 'all'; game = ''; }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Servers · Electric Cherry</title></svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-heading">
|
||||
<SectionHeader title="Fleet" />
|
||||
<SectionHeader title="Servers" />
|
||||
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
|
||||
</div>
|
||||
<div class="sr-only" role="status" aria-atomic="true">{announcement}</div>
|
||||
@@ -196,11 +198,16 @@
|
||||
{#each problems as server (server.name)}
|
||||
{@const status = serverHealth(server, observationNow())}
|
||||
{@const operation = attentionOperations.find(item => item.server === server.name)}
|
||||
{@const operationIssue = operation && status.state === 'online'}
|
||||
{@const actionLabel = operationIssue ? 'Review operation' : status.actionLabel}
|
||||
<div class="attention-row">
|
||||
<div class="attention-identity"><a class="server-link" href={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a><button class="attention-pin" aria-pressed={pinned.includes(server.name)} aria-label={`${pinned.includes(server.name) ? 'Unpin' : 'Pin'} ${serverTitle(server.name)}`} onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★' : '☆'}</button></div>
|
||||
<StatusBadge state={operation && status.state === 'online' ? 'attention' : status.state} />
|
||||
<p>{operation && status.state === 'online' ? operation.label : status.reason}{#if status.since}<span title={status.since}> · Evidence {elapsedSince(status.since, $uiNow)} ago</span>{/if}</p>
|
||||
<Button size="sm" href={contextHref(operation && status.state === 'online' ? operation.href : status.actionHref, $page.url)}>{operation && status.state === 'online' ? 'Review operation' : status.actionLabel}</Button>
|
||||
<div class="attention-identity"><a class="server-link" href={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></div>
|
||||
<StatusBadge state={operationIssue ? 'attention' : status.state} />
|
||||
<p class="attention-evidence"><span class="detailed-reason">{operationIssue ? operation.label : status.reason}</span><span class="concise-reason">{operationIssue ? operation.label : status.shortReason}</span>{#if status.since}<span class="evidence-time" title={status.since}><span class="evidence-separator"> · </span>Evidence {elapsedSince(status.since, $uiNow)} ago</span>{/if}</p>
|
||||
<div class="attention-actions">
|
||||
<button class="attention-pin" aria-pressed={pinned.includes(server.name)} aria-label={`${pinned.includes(server.name) ? 'Unpin' : 'Pin'} ${serverTitle(server.name)}`} onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★' : '☆'}</button>
|
||||
<a class="attention-action" href={contextHref(operationIssue ? operation.href : status.actionHref, $page.url)} aria-label={`${actionLabel}: ${serverTitle(server.name)}`}><span class="action-label">{actionLabel}</span><span class="action-arrow" aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
@@ -316,9 +323,15 @@
|
||||
.attention h2 { padding: var(--space-3) var(--space-4); font-size: var(--text-sm); color: var(--state-warning); }
|
||||
.attention-row { display: grid; grid-template-columns: minmax(150px, 1fr) auto minmax(180px, 1.5fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); }
|
||||
.attention-identity { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
||||
.attention-pin { color: var(--text-secondary); background: none; border: 0; padding: var(--space-2); font-size: var(--text-md); }
|
||||
.attention-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-1); }
|
||||
.attention-pin { color: var(--text-secondary); background: none; border: 0; padding: var(--space-2); font-size: var(--text-md); width: 44px; min-height: 44px; flex-shrink: 0; border-radius: var(--radius-sm); }
|
||||
.attention-pin:hover, .attention-pin[aria-pressed='true'] { color: var(--accent-primary-hover); background: var(--surface-raised); }
|
||||
.attention-action { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: var(--space-2) var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--text-primary); font-size: var(--text-xs); font-weight: var(--weight-semibold); text-decoration: none; }
|
||||
.attention-action:hover { background: var(--surface-overlay); border-color: var(--border-strong); }
|
||||
.attention-pin:focus-visible, .attention-action:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
|
||||
.concise-reason, .action-arrow { display: none; }
|
||||
.attention-row p { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.attention-row p span { white-space: nowrap; }
|
||||
.attention-row .evidence-time { white-space: nowrap; }
|
||||
.server-link { color: var(--text-primary); text-decoration: none; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
.server-link:hover { text-decoration: underline; }
|
||||
.fleet-section { display: grid; gap: var(--space-3); min-width: 0; }
|
||||
@@ -339,7 +352,7 @@
|
||||
.pin { font-size: var(--text-md); }
|
||||
.fleet-footer { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 1000px) { .attention-row { grid-template-columns: 1fr auto; } .attention-row p { grid-column: 1; } }
|
||||
@media (max-width: 1000px) { .attention-row { grid-template-columns: minmax(0, 1fr) auto; } .attention-row p { grid-column: 1; } }
|
||||
@media (max-width: 600px) {
|
||||
.page { gap: var(--space-3); }
|
||||
.overview { gap: var(--space-1) var(--space-3); font-size: var(--text-xs); }
|
||||
@@ -348,9 +361,15 @@
|
||||
.filter-row { flex-wrap: wrap; }
|
||||
.chips { flex-wrap: nowrap; overflow-x: auto; width: 100%; padding: 3px; }
|
||||
.filter-chip { flex-shrink: 0; min-height: 44px; }
|
||||
.attention-row { gap: var(--space-2); padding: var(--space-3); }
|
||||
.attention-row p { grid-column: 1; }
|
||||
.attention-row :global(.btn) { grid-column: 2; grid-row: 2; justify-self: end; padding-inline: var(--space-2); font-size: var(--text-xs); }
|
||||
.attention-row { gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); }
|
||||
.attention-identity .server-link { display: flex; align-items: center; min-height: 44px; }
|
||||
.attention-identity :global(small), .detailed-reason, .action-label, .evidence-separator { display: none; }
|
||||
.concise-reason { display: inline; }
|
||||
.attention-row .attention-evidence { grid-column: 1; overflow-wrap: anywhere; }
|
||||
.attention-row .evidence-time { display: block; white-space: normal; }
|
||||
.attention-actions { grid-column: 2; grid-row: 2; align-self: center; }
|
||||
.attention-action { width: 44px; padding: 0; flex-shrink: 0; }
|
||||
.action-arrow { display: inline; font-size: var(--text-lg); }
|
||||
.attention h2 { padding: var(--space-3); }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
@@ -202,7 +201,6 @@
|
||||
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
|
||||
<BackLink fallback="/betas" />
|
||||
<SectionHeader title="New beta" subtitle="Test files across games in one beta. Choose one instance count for every game." />
|
||||
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
|
||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
@@ -98,9 +97,8 @@
|
||||
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title={collection?.name ?? 'Collection launch'}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
|
||||
{#snippet crumbs()}<Breadcrumbs fallback="/collections?view=active" items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback="/collections?view=active" />
|
||||
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if collection && detail}
|
||||
<section class="summary" aria-label="Collection progress">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
let search = $state(''); let status = $state('');
|
||||
let loadSequence = 0;
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
$effect(() => { if (canManage && $page.url.searchParams.get('action') === 'add') addOpen = true; });
|
||||
const attention = $derived(hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, now).key)).length);
|
||||
const ready = $derived(hosts.filter(host => hostListStatus(host, now).key === 'ready').length);
|
||||
const filtersVisible = $derived(hosts.length > 5 || !!search || !!status);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -73,7 +72,6 @@
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
|
||||
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback="/hosts" />
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
|
||||
@@ -84,7 +82,6 @@
|
||||
<Tabs {tabs} value={tab} onchange={changeTab} ariaLabel="Host sections" idPrefix="host-tabs" panelId="host-panel" />
|
||||
<div class="host-panel" id="host-panel" role="tabpanel" aria-labelledby={`host-tabs-${tab}`}>
|
||||
{#if tab === 'overview'}
|
||||
<div class="overview-actions"><Button onclick={() => changeTab('servers')}>View {instances.length} servers</Button></div>
|
||||
<section class="panel"><h2>Agent workers</h2>
|
||||
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)}
|
||||
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: {lane.lastPollAt ? new Date(lane.lastPollAt).toLocaleString() : 'Not yet reported'}</dd>
|
||||
@@ -161,6 +158,6 @@
|
||||
.host-menu>div { position:absolute; right:0; top:100%; z-index:5; padding:12px; min-width:160px; background:var(--surface-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); }
|
||||
|
||||
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
|
||||
.host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; }
|
||||
.host-panel { display:grid; gap:24px; min-width:0; }
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
@@ -64,10 +63,9 @@
|
||||
|
||||
<section class="profile">
|
||||
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
|
||||
{#snippet crumbs()}<Breadcrumbs fallback={data.returnTo} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback={data.returnTo} />
|
||||
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
|
||||
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
|
||||
@@ -103,7 +102,6 @@
|
||||
{#if canDismiss}<Button variant="ghost" disabled={!!busy || activeOperation} loading={busy === 'dismiss'} onclick={() => update('dismiss')}>Dismiss incident</Button>{/if}
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback="/maintenance" />
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError} {detail ? 'Showing the last successful refresh.' : ''}<Button size="sm" disabled={!!busy} onclick={load}>Retry refresh</Button></div>{/if}
|
||||
{#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import type { MatchDetail } from '$lib/match-history.js';
|
||||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
@@ -37,7 +36,6 @@
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Match History', href: '/matches' }, { label: `Match #${match.id}` }]} />{/snippet}
|
||||
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback="/matches" />
|
||||
|
||||
<section class="summary" aria-label="Match summary">
|
||||
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
|
||||
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
||||
@@ -224,7 +224,7 @@
|
||||
{#if server}
|
||||
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
||||
<header class="instance-header">
|
||||
<BackLink fallback="/" />
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
||||
<SectionHeader title={serverTitle(server.name)}>
|
||||
{#snippet actions()}
|
||||
<details class="server-actions"><summary>Instance actions</summary><div class="action-menu">{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue instance removal</Button></div></details>
|
||||
|
||||
Reference in New Issue
Block a user