Files
electric-cherry/src/routes/+layout.svelte
T

338 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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` deployments,
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/deployments', a: '/campaigns', 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: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns|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: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Campaigns' }, { 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','Server deployments'],['g a','Campaigns'],['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>