Add sidebar navigation and global command search

This commit is contained in:
2026-10-07 18:59:39 -04:00
parent 9999e80226
commit e4f37f6964
33 changed files with 660 additions and 151 deletions
+37 -50
View File
@@ -1,61 +1,48 @@
<script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/stores';
import type { UserRole } from '$lib/auth.js';
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
import { navigationItemActive, visibleNavigation } from '$lib/navigation.js';
import { serverHealth } from '$ui/server-health.js';
import { fleet, fleetServers } from '$ui/stores/fleet.js';
import { uiNow } from '$ui/stores/servers.js';
let { role, variant = 'primary' }: { role: UserRole; variant?: 'primary' | 'secondary' } = $props();
let navigation = $state<HTMLElement>();
let { role, onNavigate }: { role: UserRole; onNavigate?: () => void } = $props();
const id = $props.id();
const groups = $derived(visibleNavigation(role));
const location = $derived(navigationLocation($page.url.pathname, role));
// Keep the current section or page visible in horizontally scrolling rows.
$effect(() => {
const current = location;
const node = navigation;
if (!current || !node) return;
let cancelled = false;
let observer: ResizeObserver | undefined;
void tick().then(() => {
if (cancelled) return;
const selected = node.querySelector<HTMLElement>('[aria-current]');
if (!selected) return;
const reveal = () => {
const bounds = node.getBoundingClientRect();
const target = selected.getBoundingClientRect();
if (target.left < bounds.left) node.scrollLeft -= bounds.left - target.left;
else if (target.right > bounds.right) node.scrollLeft += target.right - bounds.right;
};
reveal();
observer = new ResizeObserver(reveal);
observer.observe(node);
});
return () => { cancelled = true; observer?.disconnect(); };
const attention = $derived.by(() => {
if (!$fleet.receivedAt) return null;
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
const hosts = new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
});
</script>
{#if variant === 'primary'}
<nav class="primary" aria-label="Primary" bind:this={navigation}>
{#each groups as group (group.id)}
<a href={group.items[0].href} aria-current={location?.group.id === group.id ? 'location' : undefined}>{group.label}</a>
{/each}
</nav>
{:else if location}
<nav class="secondary" aria-label={`${location.group.label} pages`} bind:this={navigation}>
{#each location.group.items as item (item.href)}
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
{/each}
</nav>
{/if}
<nav aria-label="Main navigation">
{#each groups as group (group.id)}
<section aria-labelledby={`${id}-${group.id}`}>
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
<ul>{#each group.items as item (item.href)}
<li><a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}
aria-label={attention && attention[item.href] > 0 ? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention` : undefined} onclick={() => onNavigate?.()}>
<span>{item.label}</span>
{#if attention && attention[item.href] > 0}<span class="attention" aria-hidden="true">{attention[item.href]}</span>{/if}
</a></li>
{/each}</ul>
</section>
{/each}
</nav>
<style>
nav { display:flex; align-items:stretch; gap:4px; min-width:0; max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; }
a { display:flex; align-items:center; flex-shrink:0; min-height:44px; padding:10px 16px; border-bottom:2px solid transparent; color:var(--text-secondary); text-decoration:none; white-space:nowrap; font-size:var(--text-sm); font-weight:500; }
a:hover { color:var(--text-primary); background:var(--surface-raised); }
a:focus-visible { outline-offset:-3px; }
a[aria-current] { color:var(--accent-primary-hover); border-bottom-color:var(--accent-primary); }
.primary a { min-height:59px; }
.secondary { margin-bottom:var(--space-6); border-bottom:1px solid var(--border-subtle); }
@media (max-width:999px) { .primary a { min-height:48px; } }
@media (max-width:720px) { a { padding-inline:12px; } }
nav { display: grid; gap: var(--space-4); min-width: 0; }
h2 { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; }
a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 32px; padding: 5px var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; }
a:hover { color: var(--text-primary); background: var(--surface-raised); }
a[aria-current] { color: var(--text-primary); background: var(--surface-raised); box-shadow: inset 2px 0 var(--accent-primary); font-weight: var(--weight-semibold); }
a:focus-visible { outline-offset: -2px; }
.attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
@media (max-width: 960px) { a { min-height: 44px; } }
</style>