diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts new file mode 100644 index 0000000..7413c93 --- /dev/null +++ b/src/lib/navigation.ts @@ -0,0 +1,52 @@ +import type { UserRole } from './auth.js'; + +export interface NavigationItem { label: string; href: string; shortcut?: string; matches?: (path: string) => boolean } +export interface NavigationGroup { id: string; label: string; admin?: boolean; items: NavigationItem[] } + +export const navigationGroups: NavigationGroup[] = [ + { id: 'fleet', label: 'Fleet', items: [ + { label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') }, + { label: 'Collections', href: '/collections' }, + { label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) }, + { label: 'Capacity', href: '/capacity' } + ] }, + { id: 'library', label: 'Library', items: [ + { label: 'Scripts', href: '/scripts', shortcut: 's' }, + { label: 'Configs', href: '/configs', shortcut: 'c' }, + { label: 'Plugins', href: '/plugins', shortcut: 'p' }, + { label: 'Game Files', href: '/game-files' } + ] }, + { id: 'operations', label: 'Operations', items: [ + { label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) }, + { label: 'Betas', href: '/betas', shortcut: 'b' }, + { label: 'Campaigns', href: '/campaigns', shortcut: 'a' }, + { label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }, + { label: 'Host operation history', href: '/hosts/operations' } + ] }, + { id: 'analytics', label: 'Analytics', items: [ + { label: 'Metrics', href: '/metrics', shortcut: 'm' }, + { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, + { label: 'Match History', href: '/matches' } + ] }, + { id: 'administration', label: 'Administration', admin: true, items: [ + { label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }, + { label: 'Keys', href: '/server-keys', shortcut: 'k' }, + { label: 'Users', href: '/settings/users' } + ] } +]; + +export function visibleNavigation(role: UserRole): NavigationGroup[] { + return navigationGroups.filter(group => !group.admin || role === 'admin'); +} + +export function navigationItemActive(item: NavigationItem, path: string): boolean { + return item.matches ? item.matches(path) : path === item.href || path.startsWith(item.href + '/'); +} + +export function navigationLocation(path: string, role: UserRole) { + for (const group of visibleNavigation(role)) { + const item = group.items.find(item => navigationItemActive(item, path)); + if (item) return { group, item }; + } + return null; +} diff --git a/src/lib/ui/components/Navigation.svelte b/src/lib/ui/components/Navigation.svelte new file mode 100644 index 0000000..2a0d3de --- /dev/null +++ b/src/lib/ui/components/Navigation.svelte @@ -0,0 +1,81 @@ + + +{#snippet links(prefix: string)} + +{/snippet} + + + open = false} + onclick={event => { if (event.target === drawer) { const box = drawer!.getBoundingClientRect(); if (event.clientX > box.right || event.clientX < box.left || event.clientY < box.top || event.clientY > box.bottom) open = false; } }}> +
Navigation
+ {@render links('mobile-navigation')} +
+ + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 07db64d..fe12308 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,8 @@ import { startServerStream } from '$ui/stores/servers.js'; import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js'; import { page } from '$app/stores'; + import Navigation from '$ui/components/Navigation.svelte'; + import { navigationLocation, visibleNavigation } from '$lib/navigation.js'; import BrandMark from '$ui/components/BrandMark.svelte'; import Confirmation from '$ui/components/Confirmation.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte'; @@ -80,12 +82,7 @@ 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 = { - 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' - }; + 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; let armTimer: ReturnType | null = null; @@ -125,49 +122,26 @@ // 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: '/capacity', label: 'Capacity', match: (p: string) => p === '/capacity' }, - { 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 === 'Hosts')!.match($page.url.pathname) - ? [{ href: '/hosts', label: 'All hosts' }, { href: '/hosts/operations', label: 'Operation history' }] - : 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' }] : [])] : []); - - function secondaryActive(href: string): boolean { - const path = $page.url.pathname; - if (href === '/settings') return path === href; - if (href === '/hosts') return path === href || /^\/hosts\/(?!operations(?:\/|$))/.test(path); - return path === href || path.startsWith(href + '/'); - } + const location = $derived(navigationLocation($page.url.pathname, data.user?.role ?? 'viewer')); + const shortcuts = $derived(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut)); -
+
{#if publicPage}
{:else} -
- {#if sessionReady} - - {/if} - - {#if sessionReady && secondary.length}{/if} + + {#if sessionReady && data.user}{/if} {/if}
{#if sessionReady && data.user?.role === 'viewer'}{/if} @@ -215,7 +175,7 @@
-
{#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]}
{keys}
{label}
{/each}
+
{#each shortcuts as item}
g {item.shortcut}
{item.href === '/' ? 'Fleet' : item.label}
{/each}
?
This help
@@ -260,157 +220,48 @@ .shell { min-height: 100vh; } .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; } + .with-navigation main { margin-left:232px; } 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); } + .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; height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); } + .brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; } + .location { font-size:var(--text-sm); color:var(--text-secondary); min-width:0; } + .location strong { color:var(--text-primary); font-weight:500; } + .header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; } .account-menu { position:relative; } - .header-actions { order:2; display:flex; align-items:center; gap:var(--space-2); margin-left:auto; } - .account-button { display:grid; place-items:center; width:44px; height:44px; padding:0; border:1px solid var(--border-strong); border-radius:50%; background:color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); color:var(--accent-primary-hover); font:inherit; font-weight:var(--weight-semibold); cursor:pointer; transition:background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard); } - .account-button:hover,.account-button[aria-expanded='true'] { background:var(--surface-raised); border-color:var(--accent-primary-hover); } - .account-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; } + .account-button,.help-button,.menu-toggle { 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; } + .account-button { border-radius:50%; color:var(--accent-primary-hover); background:color-mix(in oklab,var(--accent-primary) 12%,var(--surface)); font-weight:600; } .account-links { position:absolute; right:0; top:calc(100% + 8px); width:220px; max-width:calc(100vw - 32px); max-height:calc(100dvh - 84px); overflow-y:auto; 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-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; } .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 a:hover,.account-links button:hover,.help-button:hover,.menu-toggle: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); + .menu-toggle { display:none; gap:8px; } + .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); } + 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); } + .skip-link:focus { top:12px; } + :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) { + .with-navigation main { margin-inline:auto; } + .menu-toggle { display:inline-flex; } + .location { display:none; } } - @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 { - order: 1; - 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; - } - - .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)); - } - .header-actions { order:0; } - .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; max-height:calc(100dvh - 72px - env(safe-area-inset-top)); overflow-y:auto; } - .tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); } - } - @media (max-width: 400px) { - .menu-label { display:none; } - .menu-toggle { justify-content:center; width:44px; padding:8px; } + @media (max-width:720px) { + .app-header { padding-inline:12px; gap:8px; } .brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; } + .menu-label { display:none; } + 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)); } + :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: 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); - } + @media (max-width:360px) { .brand-link :global(.mark-sm .word) { font-size:10px; letter-spacing:0; } .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); } diff --git a/tests/unit/navigation.test.ts b/tests/unit/navigation.test.ts new file mode 100644 index 0000000..7d54bdc --- /dev/null +++ b/tests/unit/navigation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { navigationGroups, navigationItemActive, navigationLocation, visibleNavigation } from '../../src/lib/navigation.js'; + +describe('application navigation', () => { + it.each([ + ['/servers/t5_zombie_theater_1', 'fleet', '/'], + ['/collections/launches/42', 'fleet', '/collections'], + ['/hosts/42', 'fleet', '/hosts'], + ['/hosts/operations', 'operations', '/hosts/operations'], + ['/betas/new', 'operations', '/betas'], + ['/campaigns/42', 'operations', '/campaigns'], + ['/configs/42', 'library', '/configs'], + ['/leaderboard/players/42', 'analytics', '/leaderboard'], + ['/settings/users', 'administration', '/settings/users'], + ['/server-keys', 'administration', '/server-keys'] + ])('gives %s exactly one canonical home', (path, group, href) => { + expect(navigationLocation(path, 'admin')?.group.id).toBe(group); + expect(navigationGroups.flatMap(group => group.items).filter(item => navigationItemActive(item, path)).map(item => item.href)).toEqual([href]); + }); + + it('keeps administration and its shortcuts out of operator and viewer navigation', () => { + for (const role of ['operator', 'viewer'] as const) { + expect(visibleNavigation(role).map(group => group.id)).not.toContain('administration'); + expect(navigationLocation('/server-keys', role)).toBeNull(); + expect(visibleNavigation(role).flatMap(group => group.items).filter(item => ['h', 'k'].includes(item.shortcut ?? ''))).toEqual([]); + } + }); +});