Organize navigation into a responsive grouped sidebar
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import type { UserRole } from '$lib/auth.js';
|
||||||
|
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
|
||||||
|
|
||||||
|
let { role, open = $bindable(false) }: { role: UserRole; open?: boolean } = $props();
|
||||||
|
let collapsed = $state<Record<string, boolean>>({});
|
||||||
|
let drawer = $state<HTMLDialogElement>();
|
||||||
|
const groups = $derived(visibleNavigation(role));
|
||||||
|
const location = $derived(navigationLocation($page.url.pathname, role));
|
||||||
|
const storageKey = 'electric-cherry:navigation';
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
try { localStorage.setItem(storageKey, JSON.stringify(collapsed)); } catch { /* Navigation works without storage. */ }
|
||||||
|
}
|
||||||
|
function toggle(id: string) { collapsed = { ...collapsed, [id]: !collapsed[id] }; save(); }
|
||||||
|
$effect(() => {
|
||||||
|
const path = $page.url.pathname;
|
||||||
|
const currentRole = role;
|
||||||
|
untrack(() => {
|
||||||
|
const current = navigationLocation(path, currentRole);
|
||||||
|
if (current) collapsed = { ...collapsed, [current.group.id]: false };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (open && drawer && !drawer.open) drawer.showModal();
|
||||||
|
else if (!open && drawer?.open) drawer.close();
|
||||||
|
});
|
||||||
|
onMount(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
|
||||||
|
if (saved && typeof saved === 'object') collapsed = Object.fromEntries(groups.map(group => [group.id, saved[group.id] === true]));
|
||||||
|
} catch { /* Ignore invalid or unavailable preferences. */ }
|
||||||
|
if (location) collapsed = { ...collapsed, [location.group.id]: false };
|
||||||
|
const desktop = matchMedia('(min-width: 1000px)');
|
||||||
|
const resized = () => { if (desktop.matches) open = false; };
|
||||||
|
desktop.addEventListener('change', resized);
|
||||||
|
return () => desktop.removeEventListener('change', resized);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet links(prefix: string)}
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<section class:current={location?.group.id === group.id}>
|
||||||
|
<button class="group" aria-expanded={!collapsed[group.id]} aria-controls={`${prefix}-${group.id}`} onclick={() => toggle(group.id)}>
|
||||||
|
{group.label}<span aria-hidden="true">{collapsed[group.id] ? '+' : '−'}</span>
|
||||||
|
</button>
|
||||||
|
<div id={`${prefix}-${group.id}`} hidden={collapsed[group.id]}>
|
||||||
|
{#each group.items as item (item.href)}
|
||||||
|
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<aside class="sidebar">{@render links('desktop-navigation')}</aside>
|
||||||
|
<dialog class="drawer" id="navigation-drawer" bind:this={drawer} aria-label="Navigation" onclose={() => 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; } }}>
|
||||||
|
<div class="drawer-heading"><strong>Navigation</strong><button aria-label="Close navigation" onclick={() => open = false}>×</button></div>
|
||||||
|
{@render links('mobile-navigation')}
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar { position: fixed; inset: 60px auto 0 0; width: 232px; overflow-y: auto; overscroll-behavior: contain; background: var(--surface); border-right: 1px solid var(--border-subtle); padding: 16px 12px 32px; z-index: 15; }
|
||||||
|
nav { display: grid; gap: 12px; }
|
||||||
|
.group { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 8px 12px; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-weight: 600; background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer; }
|
||||||
|
.current .group { color: var(--text-primary); }
|
||||||
|
a { display: block; padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-sm); line-height: 1.5; }
|
||||||
|
a:hover, .group:hover { background: var(--surface-raised); color: var(--text-primary); }
|
||||||
|
a[aria-current] { color: var(--accent-primary-hover); background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); box-shadow: inset 3px 0 var(--accent-primary); }
|
||||||
|
.drawer { position: fixed; inset: 0 auto 0 0; margin: 0; width: min(320px, 90vw); height: 100dvh; max-height: 100dvh; border: 0; border-right: 1px solid var(--border-strong); border-radius: 0; padding: max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom)); color: var(--text-primary); background: var(--surface); }
|
||||||
|
.drawer::backdrop { background: #0008; }
|
||||||
|
.drawer-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.drawer-heading button { width: 44px; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); color: inherit; font: inherit; font-size: 24px; cursor: pointer; }
|
||||||
|
.drawer a, .drawer .group { min-height: 44px; display: flex; align-items: center; }
|
||||||
|
@media (max-width: 999px) { .sidebar { display: none; } }
|
||||||
|
</style>
|
||||||
+43
-192
@@ -6,6 +6,8 @@
|
|||||||
import { startServerStream } from '$ui/stores/servers.js';
|
import { startServerStream } from '$ui/stores/servers.js';
|
||||||
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
|
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
|
||||||
import { page } from '$app/stores';
|
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 BrandMark from '$ui/components/BrandMark.svelte';
|
||||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
@@ -80,12 +82,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
const stopTimeZone = startTimeZonePreference();
|
const stopTimeZone = startTimeZonePreference();
|
||||||
|
|
||||||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
|
const shortcuts = Object.fromEntries(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut).map(item => [item.shortcut!, item.href]));
|
||||||
// `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 armed = false;
|
||||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -125,49 +122,26 @@
|
|||||||
// Non-Chromium browsers fall through to plain navigation.
|
// Non-Chromium browsers fall through to plain navigation.
|
||||||
onNavigate(enableViewTransitions);
|
onNavigate(enableViewTransitions);
|
||||||
|
|
||||||
const tabs = [
|
const location = $derived(navigationLocation($page.url.pathname, data.user?.role ?? 'viewer'));
|
||||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
const shortcuts = $derived(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut));
|
||||||
{ 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 + '/');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
|
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell" class:with-navigation={sessionReady}>
|
||||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
{#if publicPage}
|
{#if publicPage}
|
||||||
<header class="auth-brand"><BrandMark size="md" /></header>
|
<header class="auth-brand"><BrandMark size="md" /></header>
|
||||||
{:else}
|
{:else}
|
||||||
<nav aria-label="Primary">
|
<header class="app-header">
|
||||||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||||||
<BrandMark size="sm" />
|
<BrandMark size="sm" />
|
||||||
</a>
|
</a>
|
||||||
|
{#if sessionReady && location}<div class="location"><span>{location.group.label}</span><span aria-hidden="true"> / </span><strong>{location.item.label}</strong></div>{/if}
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
{#if sessionReady}<button class="help-button" onclick={openHelp} aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)">?</button>{/if}
|
||||||
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
|
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
|
||||||
aria-label={menuOpen ? 'Close menu' : 'Menu'} aria-expanded={menuOpen} aria-controls="primary-links"
|
aria-label={menuOpen ? 'Close menu' : 'Menu'} aria-expanded={menuOpen} aria-controls="navigation-drawer"
|
||||||
onclick={() => { menuOpen = !menuOpen; accountOpen = false; }}>
|
onclick={() => { menuOpen = !menuOpen; accountOpen = false; }}>
|
||||||
<span class="menu-label">{menuOpen ? 'Close' : 'Menu'}</span>
|
<span class="menu-label">{menuOpen ? 'Close' : 'Menu'}</span>
|
||||||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||||||
@@ -192,22 +166,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if sessionReady}
|
</header>
|
||||||
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
|
{#if sessionReady && data.user}<Navigation role={data.user.role} bind:open={menuOpen} />{/if}
|
||||||
{#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}
|
|
||||||
</nav>
|
|
||||||
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={secondaryActive(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
<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}
|
{#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}
|
||||||
@@ -215,7 +175,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<Confirmation />
|
<Confirmation />
|
||||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
<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>
|
<dl class="shortcuts">{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,157 +220,48 @@
|
|||||||
|
|
||||||
.shell { min-height: 100vh; }
|
.shell { min-height: 100vh; }
|
||||||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
.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; }
|
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; }
|
.account-menu { position:relative; }
|
||||||
.header-actions { order:2; display:flex; align-items:center; gap:var(--space-2); margin-left:auto; }
|
.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 { 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 { border-radius:50%; color:var(--accent-primary-hover); background:color-mix(in oklab,var(--accent-primary) 12%,var(--surface)); font-weight:600; }
|
||||||
.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-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-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,.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); }
|
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||||||
|
.menu-toggle { display:none; gap:8px; }
|
||||||
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 {
|
|
||||||
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 { display:grid; gap:12px; margin:0; }
|
||||||
.shortcuts > div { display:flex; gap:20px; align-items:center; }
|
.shortcuts > div { display:flex; gap:20px; align-items:center; }
|
||||||
.shortcuts dt { min-width:55px; }
|
.shortcuts dt { min-width:55px; }
|
||||||
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
.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; }
|
kbd { font-family:var(--font-mono); padding:3px 8px; border:1px solid var(--border-strong); border-radius:5px; }
|
||||||
.menu-toggle { display: none; }
|
.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; }
|
||||||
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; }
|
|
||||||
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
|
|
||||||
}
|
|
||||||
@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) { min-width:0; max-width:100%; }
|
||||||
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
|
: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; }
|
: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); }
|
@media (max-width:999px) {
|
||||||
.skip-link:focus { top: 12px; }
|
.with-navigation main { margin-inline:auto; }
|
||||||
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
|
.menu-toggle { display:inline-flex; }
|
||||||
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
|
.location { display:none; }
|
||||||
.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)),
|
@media (max-width:720px) {
|
||||||
:global(::view-transition-new(main)) {
|
.app-header { padding-inline:12px; gap:8px; }
|
||||||
animation-duration: var(--dur-base);
|
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
|
||||||
animation-timing-function: var(--ease-standard);
|
.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: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); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user