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
@@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -288,7 +287,6 @@
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet}
</SectionHeader>
<BackLink fallback={basePath} />
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={contextHref(betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), $page.url)}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import type { UserRole } from '$lib/auth.js';
import Dialog from './primitives/Dialog.svelte';
import { loadPaletteDirectory, paletteDirectoryResults, paletteGroups, paletteMatchJump, palettePageResults, searchPaletteRecords,
type PaletteDirectory, type PaletteItem } from '$ui/command-palette.js';
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
const id = $props.id();
let input = $state<HTMLInputElement>();
let query = $state('');
let directory = $state<PaletteDirectory | null>(null);
let records = $state<PaletteItem[]>([]);
let directoryLoading = $state(false);
let recordsLoading = $state(false);
let recordErrors = $state<string[]>([]);
let selected = $state('');
let navigationError = $state('');
let navigating = $state(false);
const items = $derived.by(() => {
const unique = new Map<string, PaletteItem>();
for (const item of [...palettePageResults(role, query), ...paletteDirectoryResults(query, directory), ...paletteMatchJump(query), ...records]) unique.set(item.key, item);
return paletteGroups.flatMap(group => [...unique.values()].filter(item => item.group === group));
});
const activeIndex = $derived(Math.max(0, items.findIndex(item => item.key === selected)));
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
const searching = $derived(directoryLoading || recordsLoading);
$effect(() => {
if (!open) { query = ''; records = []; directory = null; navigationError = ''; return; }
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
directoryLoading = true;
void loadPaletteDirectory(controller.signal).then(result => { if (!cancelled) directory = result; })
.finally(() => { clearTimeout(timeout); if (!cancelled) directoryLoading = false; });
void tick().then(() => { if (!cancelled) input?.focus(); });
return () => { cancelled = true; controller.abort(); clearTimeout(timeout); };
});
$effect(() => {
const term = query.trim();
selected = ''; records = []; recordErrors = [];
if (!open || term.length < 2) { recordsLoading = false; return; }
let cancelled = false;
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
recordsLoading = true;
const debounce = setTimeout(() => {
timeout = setTimeout(() => controller.abort(), 15_000);
void searchPaletteRecords(term, controller.signal).then(result => {
if (!cancelled) { records = result.items; recordErrors = result.unavailable; }
}).finally(() => { clearTimeout(timeout); if (!cancelled) recordsLoading = false; });
}, 220);
return () => { cancelled = true; clearTimeout(debounce); clearTimeout(timeout); controller.abort(); };
});
$effect(() => {
const option = activeIndex;
if (!open || !items.length) return;
void tick().then(() => document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' }));
});
async function choose(item: PaletteItem) {
if (navigating) return;
navigating = true; navigationError = '';
try { await goto(item.href); open = false; }
catch { navigationError = 'Could not open that result. Try again.'; }
finally { navigating = false; }
}
function keydown(event: KeyboardEvent) {
if (!items.length || navigating) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const movement = event.key === 'ArrowDown' ? 1 : -1;
selected = items[(activeIndex + movement + items.length) % items.length].key;
} else if (event.key === 'Enter') {
event.preventDefault(); void choose(items[activeIndex]);
}
}
</script>
<Dialog bind:open title="Search and commands" description="Jump to a page, server, host, player, or match.">
<div class="palette">
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
<input bind:this={input} bind:value={query} id={`${id}-search`} type="search" maxlength="100" placeholder="Search Electric Cherry…"
role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-results`}
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined} aria-describedby={`${id}-hint`}
autocomplete="off" onkeydown={keydown} />
<p class="hint" id={`${id}-hint`}>Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span></p>
<div class="results" id={`${id}-results`} role="listbox" aria-label="Search results" aria-busy={searching || navigating}>
{#each paletteGroups as group}
{@const groupItems = items.filter(item => item.group === group)}
{#if groupItems.length}
<div role="group" aria-labelledby={`${id}-${group}`}>
<div class="group-label" id={`${id}-${group}`}>{group}</div>
{#each groupItems as item (item.key)}
{@const index = items.indexOf(item)}
<button type="button" id={`${id}-option-${index}`} role="option" tabindex="-1" aria-selected={index === activeIndex}
class:active={index === activeIndex} disabled={navigating} onclick={() => void choose(item)} onpointermove={() => selected = item.key}>
<span class="result-label">{item.label}</span><span class="result-detail">{item.detail}</span>
</button>
{/each}
</div>
{/if}
{/each}
</div>
<p class="status" role="status">{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}</p>
{#if unavailable.length}<p class="error">{unavailable.join(', ')} search is unavailable. Other results are still available.</p>{/if}
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
</div>
</Dialog>
<style>
.palette { display:grid; gap:var(--space-3); min-width:0; }
input { width:100%; min-height:44px; padding:var(--space-3); font:inherit; font-size:var(--text-base); color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
.hint,.status,.error { margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.5; }
.hint span { display:block; }
.results { min-height:40px; max-height:min(55dvh,480px); overflow:auto; overscroll-behavior:contain; position:relative; }
.group-label { padding:var(--space-3) var(--space-2) var(--space-1); font-size:var(--text-xs); color:var(--text-tertiary); font-weight:var(--weight-semibold); }
button { display:grid; gap:var(--space-1); width:100%; min-height:48px; text-align:left; border:1px solid transparent; border-radius:var(--radius-sm); padding:var(--space-2) var(--space-3); background:transparent; color:var(--text-primary); font:inherit; cursor:pointer; }
button.active { background:var(--surface-raised); border-color:var(--accent-primary); }
button:disabled { cursor:wait; }
.result-label { font-size:var(--text-sm); overflow-wrap:anywhere; }
.result-detail { color:var(--text-secondary); font-size:var(--text-xs); overflow-wrap:anywhere; }
.error { color:var(--state-warning); }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
</style>
+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>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { navigationLocation } from '$lib/navigation.js';
import { returnLabel, safeReturnHref } from '$lib/navigation-context.js';
interface Crumb {
label: string;
href?: string | null;
@@ -8,11 +9,24 @@
interface Props {
items: Crumb[];
fallback?: string;
}
let { items }: Props = $props();
let { items, fallback }: Props = $props();
const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group);
const trail = $derived(group && group.label !== items[0]?.label ? [{ label: group.label }, ...items] : items);
const trail = $derived.by(() => {
const parent = items.findIndex(item => item.href);
const context = $page.url.searchParams.get('returnTo');
const result = [...items];
if (parent >= 0) {
const href = safeReturnHref(context, fallback ?? items[parent].href!);
const original = items[parent];
const label = href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
? original.label : returnLabel(href).replace(/^Back to /, '').replace(/^./, char => char.toUpperCase());
result[parent] = { label, href };
}
return !context && group && group.label !== result[0]?.label ? [{ label: group.label }, ...result] : result;
});
</script>
<nav class="crumbs" aria-label="Breadcrumb">