338 lines
16 KiB
Svelte
338 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { startTimeZonePreference } from '$ui/stores/time-zone.js';
|
||
import { afterNavigate, onNavigate, goto } from '$app/navigation';
|
||
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||
import { startServerStream } from '$ui/stores/servers.js';
|
||
import { startFleetMonitor } from '$ui/stores/fleet.js';
|
||
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 NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||
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.
|
||
import '$ui/styles/fonts.js';
|
||
|
||
interface Props { children: import('svelte').Snippet }
|
||
let { children }: Props = $props();
|
||
let menuOpen = $state(false);
|
||
let notificationsOpen = $state(false);
|
||
let helpOpen = $state(false);
|
||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||
let menuButton: HTMLButtonElement;
|
||
|
||
function openUtility(panel: 'notifications' | 'help') {
|
||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||
if (panel === 'notifications') notificationsOpen = true;
|
||
else helpOpen = true;
|
||
}
|
||
|
||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||
|
||
function closeMenu(event: KeyboardEvent) {
|
||
if (event.key === 'Escape' && menuOpen) {
|
||
menuOpen = false;
|
||
menuButton?.focus();
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
const stopTimeZone = startTimeZonePreference();
|
||
const stopNotifications = startNotificationHistory();
|
||
const stopPipeline = startPipelineStream();
|
||
const stopServers = startServerStream();
|
||
const stopFleetMonitor = startFleetMonitor();
|
||
|
||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
|
||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||
const shortcuts: Record<string, string> = {
|
||
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
|
||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics'
|
||
};
|
||
let armed = false;
|
||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const onKey = (e: KeyboardEvent) => {
|
||
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;
|
||
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; }
|
||
|
||
if (!armed && e.key === 'g') {
|
||
armed = true;
|
||
if (armTimer) clearTimeout(armTimer);
|
||
armTimer = setTimeout(() => { armed = false; }, 1000);
|
||
return;
|
||
}
|
||
if (armed) {
|
||
armed = false;
|
||
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
|
||
const dest = shortcuts[e.key];
|
||
if (dest) {
|
||
e.preventDefault();
|
||
goto(dest);
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
stopTimeZone();
|
||
stopNotifications();
|
||
stopFleetMonitor();
|
||
stopPipeline?.();
|
||
stopServers?.();
|
||
window.removeEventListener('keydown', onKey);
|
||
if (armTimer) clearTimeout(armTimer);
|
||
};
|
||
});
|
||
|
||
// Route changes cross-fade via the browser's View Transitions API.
|
||
// Non-Chromium browsers fall through to plain navigation.
|
||
onNavigate(enableViewTransitions);
|
||
|
||
const tabs = [
|
||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
|
||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
|
||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
|
||
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
|
||
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
|
||
];
|
||
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: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, { href: '/server-keys', label: 'Keys' }]
|
||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []);
|
||
</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" />
|
||
</a>
|
||
<button class="menu-toggle" bind:this={menuButton} type="button"
|
||
aria-expanded={menuOpen} aria-controls="primary-links"
|
||
onclick={() => { menuOpen = !menuOpen; }}>
|
||
{menuOpen ? 'Close' : 'Menu'}
|
||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||
</button>
|
||
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
|
||
{#each tabs as t (t.href)}
|
||
{@const on = t.match($page.url.pathname)}
|
||
<a
|
||
href={t.href}
|
||
class:on
|
||
aria-current={on ? 'page' : undefined}
|
||
>
|
||
<span class="lbl">{t.label}</span>
|
||
</a>
|
||
{/each}
|
||
</div>
|
||
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
|
||
</nav>
|
||
{#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 />
|
||
<NotificationHistory bind:open={notificationsOpen} />
|
||
<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 [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],['g h','Settings'],['g k','Keys'],['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||
</Dialog>
|
||
</div>
|
||
|
||
<style>
|
||
@import '$ui/styles/tokens.css';
|
||
|
||
:global(html, body) {
|
||
margin: 0;
|
||
background: var(--surface-sunken);
|
||
color: var(--text-primary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-body);
|
||
min-height: 100vh;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
:global(*) { box-sizing: border-box; }
|
||
:global(a) { color: var(--accent-primary-hover); }
|
||
:global(::selection) {
|
||
background: var(--accent-primary);
|
||
color: var(--text-on-accent);
|
||
}
|
||
:global(:focus-visible) {
|
||
outline: 2px solid var(--accent-primary-hover);
|
||
outline-offset: 3px;
|
||
}
|
||
|
||
/* Ambient background — a faint radial gradient hints at depth without
|
||
any lines. Fixed to the viewport so scrolling stays quiet. */
|
||
:global(body::before) {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background:
|
||
radial-gradient(1200px 700px at 20% -10%, rgba(46, 134, 209, 0.10), transparent 60%),
|
||
radial-gradient(900px 600px at 90% 100%, rgba(124, 192, 245, 0.05), transparent 60%);
|
||
z-index: -1;
|
||
}
|
||
|
||
.shell { min-height: 100vh; }
|
||
|
||
nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-6);
|
||
height: 60px;
|
||
padding: 0 var(--space-8);
|
||
background: color-mix(in oklab, var(--surface) 75%, transparent);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
@supports (backdrop-filter: blur(4px)) {
|
||
nav {
|
||
backdrop-filter: blur(20px) saturate(1.4);
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||
background: color-mix(in oklab, var(--surface) 62%, transparent);
|
||
}
|
||
}
|
||
|
||
.brand-link {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
text-decoration: none;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.brand-link:focus-visible { box-shadow: var(--focus-ring); }
|
||
|
||
.tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
margin-left: var(--space-4);
|
||
}
|
||
.tabs a {
|
||
white-space: nowrap;
|
||
position: relative;
|
||
color: var(--text-tertiary);
|
||
text-decoration: none;
|
||
padding: 8px 14px;
|
||
border-radius: var(--radius-pill);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--weight-medium);
|
||
letter-spacing: var(--tracking-normal);
|
||
transition:
|
||
color var(--dur-fast) var(--ease-standard),
|
||
background-color var(--dur-fast) var(--ease-standard);
|
||
}
|
||
.tabs a:hover { color: var(--text-primary); }
|
||
.tabs a:focus-visible { box-shadow: var(--focus-ring); }
|
||
.tabs a.on {
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
/* Shared-element transition — pill slides smoothly between tabs
|
||
when navigating. Chromium only; falls back to instant swap. */
|
||
view-transition-name: nav-active;
|
||
}
|
||
|
||
.nav-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||
.utility { background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer; font: inherit; font-size: var(--text-xs); white-space: nowrap; }
|
||
.shortcuts { display: grid; gap: 12px; margin: 0; }
|
||
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
|
||
.shortcuts dt { min-width: 55px; }
|
||
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
|
||
.menu-toggle { display: none; }
|
||
|
||
main {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
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: 1200px) {
|
||
nav {
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
gap: 0;
|
||
height: auto;
|
||
min-height: 60px;
|
||
padding: calc(8px + env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) 8px max(var(--space-4), env(safe-area-inset-left));
|
||
max-height: 100dvh;
|
||
overflow-y: auto;
|
||
}
|
||
.menu-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
min-height: 44px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface-raised);
|
||
color: var(--text-primary);
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
}
|
||
.menu-toggle:focus-visible { box-shadow: var(--focus-ring); }
|
||
.tabs { display: none; width: 100%; margin: 0; }
|
||
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||
.nav-tools { display: none; }
|
||
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
main { padding: var(--space-6) 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)); }
|
||
/* Keep native controls readable and prevent focus zoom on phones. */
|
||
: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; }
|
||
}
|
||
|
||
: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; }
|
||
.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)),
|
||
:global(::view-transition-new(root)) {
|
||
animation-duration: var(--dur-base);
|
||
animation-timing-function: var(--ease-standard);
|
||
}
|
||
:global(::view-transition-old(main)),
|
||
:global(::view-transition-new(main)) {
|
||
animation-duration: var(--dur-base);
|
||
animation-timing-function: var(--ease-standard);
|
||
}
|
||
</style>
|