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
+88
View File
@@ -0,0 +1,88 @@
import type { UserRole } from '../auth.js';
import { visibleNavigation } from '../navigation.js';
import { playerProfileHref } from '../leaderboard.js';
import { serverTitle } from './server-labels.js';
export type PaletteGroup = 'Pages' | 'Actions' | 'Servers' | 'Hosts' | 'Players' | 'Matches';
export interface PaletteItem { key: string; group: PaletteGroup; label: string; detail: string; href: string }
export interface PaletteServer { name: string; map?: string | null; last_map?: string | null; host?: { name: string } | null }
export interface PaletteHost { id: string; name: string; hostname?: string; connectionState?: string }
export interface PaletteDirectory { servers: PaletteServer[]; hosts: PaletteHost[]; unavailable: string[] }
type PaletteFetch = (input: string, init: RequestInit) => Promise<Response>;
export const paletteGroups: PaletteGroup[] = ['Actions', 'Pages', 'Servers', 'Hosts', 'Players', 'Matches'];
function matches(query: string, ...values: Array<string | null | undefined>) {
const text = values.filter(Boolean).join(' ').toLocaleLowerCase();
return query.trim().toLocaleLowerCase().split(/\s+/).every(word => text.includes(word));
}
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }];
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
return [...actions.filter(item => matches(query, item.label, item.detail)),
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
key: `page:${item.href}`, group: 'Pages' as const, label: item.label, detail: group.label, href: item.href
})))];
}
export function paletteDirectoryResults(query: string, directory: PaletteDirectory | null): PaletteItem[] {
if (!query.trim() || !directory) return [];
const servers: PaletteItem[] = directory.servers.filter(server => matches(query, serverTitle(server.name), server.name, server.map, server.last_map, server.host?.name))
.slice(0, 6).map(server => ({ key: `server:${server.name}`, group: 'Servers', label: serverTitle(server.name),
detail: server.host?.name ?? 'Game server', href: `/servers/${encodeURIComponent(server.name)}` }));
const hosts: PaletteItem[] = directory.hosts.filter(host => matches(query, host.name, host.hostname, host.id))
.slice(0, 6).map(host => ({ key: `host:${host.id}`, group: 'Hosts', label: host.name,
detail: host.connectionState === 'offline' ? 'Host agent offline' : 'Host', href: `/hosts/${encodeURIComponent(host.id)}` }));
return [...servers, ...hosts];
}
/** Match IDs can be opened directly without downloading match history. */
export function paletteMatchJump(query: string): PaletteItem[] {
const id = /^(?:match\s*#?\s*|#)?([1-9]\d*)$/i.exec(query.trim())?.[1];
if (!id || !Number.isSafeInteger(Number(id))) return [];
return [{ key: `match:${id}`, group: 'Matches', label: `Open match #${id}`, detail: 'Go to a recorded match', href: `/matches/${id}` }];
}
async function read(url: string, signal: AbortSignal, fetcher: PaletteFetch) {
const response = await fetcher(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Search request failed.');
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Search response is unavailable.');
return body as Record<string, unknown>;
}
/** Load only the current directory; historical entities use bounded queries below. */
export async function loadPaletteDirectory(signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<PaletteDirectory> {
const [servers, hosts] = await Promise.allSettled([
read('/api/servers', signal, fetcher), read('/api/hosts', signal, fetcher)
]);
return {
servers: servers.status === 'fulfilled' && Array.isArray(servers.value.servers) ? servers.value.servers.filter(server => server && typeof server.name === 'string') : [],
hosts: hosts.status === 'fulfilled' && Array.isArray(hosts.value.hosts) ? hosts.value.hosts.filter(host => host && typeof host.id === 'string' && typeof host.name === 'string') : [],
unavailable: [servers.status === 'rejected' ? 'Server' : '', hosts.status === 'rejected' ? 'Host' : ''].filter(Boolean)
};
}
export async function searchPaletteRecords(query: string, signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<{ items: PaletteItem[]; unavailable: string[] }> {
const term = query.trim().slice(0, 100);
if (term.length < 2) return { items: [], unavailable: [] };
const playersParams = new URLSearchParams({ q: term, limit: '5', metric: 'highestRound' });
const matchesParams = new URLSearchParams({ q: term, limit: '5', status: 'completed' });
const [players, recordedMatches] = await Promise.allSettled([
read(`/api/leaderboard?${playersParams}`, signal, fetcher), read(`/api/matches?${matchesParams}`, signal, fetcher)
]);
const items: PaletteItem[] = [];
if (players.status === 'fulfilled' && Array.isArray(players.value.entries)) {
for (const player of players.value.entries.slice(0, 5)) {
if (!player || typeof player.guid !== 'string' || typeof player.name !== 'string') continue;
items.push({ key: `player:${player.guid}`, group: 'Players', label: player.name, detail: 'Player profile', href: playerProfileHref(player.guid) });
}
}
if (recordedMatches.status === 'fulfilled' && Array.isArray(recordedMatches.value.matches)) {
for (const match of recordedMatches.value.matches.slice(0, 5)) {
if (!match || !Number.isSafeInteger(match.id) || match.id < 1 || match.status !== 'completed' || typeof match.map !== 'string' || typeof match.server !== 'string') continue;
items.push({ key: `match:${match.id}`, group: 'Matches', label: `${match.map} · Match #${match.id}`, detail: serverTitle(match.server), href: `/matches/${match.id}` });
}
}
return { items, unavailable: [players.status === 'rejected' ? 'Player' : '', recordedMatches.status === 'rejected' ? 'Match' : ''].filter(Boolean) };
}
@@ -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">
+14 -2
View File
@@ -8,6 +8,7 @@ export interface ServerHealthPresentation {
state: ServerHealthState;
label: string;
reason: string;
shortReason: string;
actionLabel: string;
actionHref: string;
/** Last relevant evidence, not an inferred time when the state began. */
@@ -22,10 +23,21 @@ function observedAt(value: string | null | undefined, now: number): string | nul
/** Explain the available evidence without treating a missing report as a stopped game. */
export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation {
const evidence = healthEvidence(server, now);
return { ...evidence, label: statusPresentation(evidence.state).label };
const error = decodeRconError(server.last_error);
const connectionReason = error ? {
'no-config': 'Connection not configured.', 'no-port': 'Connection port not configured.',
'send-error': 'Game connection failed.', timeout: 'No reply to the status check.',
'bad-reply': 'Unreadable game response.', 'no-players': 'Player count not reported.'
}[error.reason] : null;
const shortReason = evidence.state === 'host-offline' ? `${server.host?.name ?? 'Host'} unavailable; game may still run.`
: evidence.state === 'stopped' ? 'Container confirmed stopped.'
: evidence.state === 'not-responding' ? connectionReason ?? 'Game report out of date; players unknown.'
: evidence.state === 'unknown' ? connectionReason ?? (freshStatusTimestamp(server.last_seen_at, now) ? 'Game replied; container unverified.' : 'Game and container unverified.')
: evidence.reason;
return { ...evidence, shortReason, label: statusPresentation(evidence.state).label };
}
function healthEvidence(server: FleetServer, now: number): Omit<ServerHealthPresentation, 'label'> {
function healthEvidence(server: FleetServer, now: number): Omit<ServerHealthPresentation, 'label' | 'shortReason'> {
const href = `/servers/${encodeURIComponent(server.name)}`;
const diagnostics = `${href}?tab=diagnostics`;
const host = server.host;