395 lines
21 KiB
Svelte
395 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { startTimeZonePreference } from '$ui/stores/time-zone.js';
|
||
import { afterNavigate, beforeNavigate, onNavigate, goto } from '$app/navigation';
|
||
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||
import { startServerStream } from '$ui/stores/servers.js';
|
||
import { startFleetMonitor, hydrateFleet } 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';
|
||
import type { AuthUser } from '$lib/auth.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; data: { user: AuthUser | null; setupRequired: boolean } }
|
||
let { children, data }: Props = $props();
|
||
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
|
||
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
|
||
let menuOpen = $state(false);
|
||
let accountOpen = $state(false);
|
||
let signingOut = $state(false);
|
||
let signOutError = $state('');
|
||
let notificationsOpen = $state(false);
|
||
let helpOpen = $state(false);
|
||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||
let menuButton = $state<HTMLButtonElement>();
|
||
let accountButton = $state<HTMLButtonElement>();
|
||
|
||
function openUtility(panel: 'notifications' | 'help') {
|
||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||
if (panel === 'notifications') notificationsOpen = true;
|
||
else 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(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
|
||
|
||
function closeMenu(event: KeyboardEvent) {
|
||
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
|
||
if (event.key === 'Escape' && menuOpen) {
|
||
menuOpen = false;
|
||
menuButton?.focus();
|
||
}
|
||
}
|
||
|
||
async function signOut() {
|
||
if (signingOut) return;
|
||
signingOut = true; signOutError = '';
|
||
try {
|
||
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
|
||
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
|
||
notificationHistory.set([]);
|
||
window.location.assign('/login');
|
||
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
|
||
}
|
||
|
||
$effect(() => {
|
||
if (!sessionReady) return;
|
||
const stopNotifications = startNotificationHistory();
|
||
const stopPipeline = startPipelineStream();
|
||
const stopServers = startServerStream();
|
||
return () => { stopNotifications(); stopPipeline?.(); stopServers?.(); };
|
||
});
|
||
|
||
$effect(() => {
|
||
if (!sessionReady) return;
|
||
const path = $page.url.pathname;
|
||
if (!(path === '/' || /^\/(servers|scripts|configs|plugins)\//.test(path) || path === '/betas/new')) return;
|
||
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot) hydrateFleet($page.data.fleetSnapshot);
|
||
return startFleetMonitor();
|
||
});
|
||
|
||
onMount(() => {
|
||
const stopTimeZone = startTimeZonePreference();
|
||
|
||
// 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', o: '/hosts'
|
||
};
|
||
let armed = false;
|
||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!sessionReady) 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;
|
||
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 ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
|
||
if (dest) {
|
||
e.preventDefault();
|
||
goto(dest);
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
stopTimeZone();
|
||
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: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
|
||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.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 visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
|
||
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
|
||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
|
||
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
|
||
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
|
||
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
|
||
</script>
|
||
|
||
<svelte:window onkeydown={closeMenu} />
|
||
|
||
<div class="shell">
|
||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||
{#if publicPage}
|
||
<header class="auth-brand"><BrandMark size="md" /></header>
|
||
{:else}
|
||
<nav aria-label="Primary">
|
||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||
<BrandMark size="sm" />
|
||
</a>
|
||
{#if sessionReady}<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 visibleTabs 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>{/if}
|
||
<div class="nav-tools" class:expanded={menuOpen} class:account-only={!sessionReady}>
|
||
{#if sessionReady}<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>{/if}
|
||
{#if data.user}<div class="account-menu"><button bind:this={accountButton} class="utility account-button" aria-expanded={accountOpen} aria-controls="account-links" onclick={() => accountOpen = !accountOpen}>{data.user.displayName || data.user.username}<span class="role">{data.user.role}</span></button>
|
||
{#if accountOpen}<div class="account-links" id="account-links"><a href="/account">My account</a>{#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}</div>{/if}
|
||
</div>{/if}
|
||
</div>
|
||
</nav>
|
||
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||
{/if}
|
||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||
{#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>
|
||
<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 o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['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; }
|
||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
||
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); }
|
||
.account-menu { position:relative; }
|
||
.account-button { display:flex; align-items:center; gap:8px; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
|
||
.role { text-transform:capitalize; color:var(--text-tertiary); font-size:var(--text-xs); }
|
||
.account-links { position:absolute; right:0; top:calc(100% + 8px); min-width:200px; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
|
||
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
|
||
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
|
||
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||
|
||
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: 1500px) {
|
||
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; }
|
||
.nav-tools.account-only { display:flex; }
|
||
.account-menu { margin-left:auto; }
|
||
.account-links { position:static; margin-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>
|