Unify UI workflows and streamline fleet management
This commit is contained in:
+34
-16
@@ -7,6 +7,9 @@
|
||||
import { page } from '$app/stores';
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Toaster from '$ui/components/Toaster.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
// Side-effect import — puts @fontsource CSS on Vite's module graph so
|
||||
// the referenced woff/woff2 files get emitted into the build.
|
||||
@@ -17,7 +20,7 @@
|
||||
let menuOpen = $state(false);
|
||||
let menuButton: HTMLButtonElement;
|
||||
|
||||
afterNavigate(() => { menuOpen = false; });
|
||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
@@ -27,8 +30,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startPipelineStream();
|
||||
startServerStream();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
const stopFleetMonitor = startFleetMonitor();
|
||||
|
||||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
|
||||
@@ -42,7 +45,7 @@
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
if (!armed && e.key === 'g') {
|
||||
@@ -64,6 +67,8 @@
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
stopServers?.();
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (armTimer) clearTimeout(armTimer);
|
||||
};
|
||||
@@ -74,20 +79,23 @@
|
||||
onNavigate(enableViewTransitions);
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' },
|
||||
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
|
||||
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
|
||||
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
|
||||
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
|
||||
{ href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
|
||||
{ href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
|
||||
];
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
|
||||
<div class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<nav aria-label="Primary">
|
||||
<a class="brand-link" href="/" aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
@@ -112,10 +120,12 @@
|
||||
</div>
|
||||
<div class="strut" aria-hidden="true"></div>
|
||||
</nav>
|
||||
<main>
|
||||
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||
<main id="main-content" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Toaster />
|
||||
<Confirmation />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -140,7 +150,8 @@
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
:global(:focus-visible) {
|
||||
outline: none;
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Ambient background — a faint radial gradient hints at depth without
|
||||
@@ -223,14 +234,14 @@
|
||||
main {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-10) var(--space-10) var(--space-24);
|
||||
padding: var(--space-6) var(--space-8) var(--space-16);
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
view-transition-name: main;
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 1000px) {
|
||||
@media (max-width: 800px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
@@ -271,6 +282,13 @@
|
||||
|
||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
||||
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
|
||||
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
|
||||
.subnav a { text-decoration: none; white-space: nowrap; font-size: var(--text-sm); color: var(--text-secondary); padding: var(--space-3) 0; border-bottom: 2px solid transparent; }
|
||||
.subnav a[aria-current] { color: var(--text-primary); border-bottom-color: var(--accent-primary); }
|
||||
@media (max-width: 720px) { .subnav > div { padding-inline: var(--space-4); } }
|
||||
|
||||
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
|
||||
:global(::view-transition-old(root)),
|
||||
|
||||
Reference in New Issue
Block a user