<script lang="ts">
import { isDark, toggle } from '../stores/theme.svelte.ts';
import { getStatus, getBaseUrl } from '../stores/connection.svelte.ts';
import { search } from '../api/endpoints.ts';
import { navigate, getCurrentRoute } from '../router/index.svelte.ts';
import { setHighlightedNodes } from '../stores/graph.svelte.ts';
import type { SearchResult } from '../api/types.ts';
let status = $derived(getStatus());
let route = $derived(getCurrentRoute());
let showSearch = $derived(route.page !== 'dashboard');
let searchQuery = $state('');
let searchResults: SearchResult[] = $state([]);
let showDropdown = $state(false);
let activeIndex = $state(-1);
let searchTimer: ReturnType<typeof setTimeout>;
function handleSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
if (searchQuery.length < 2) {
searchResults = [];
showDropdown = false;
activeIndex = -1;
return;
}
try {
const response = await search(getBaseUrl(), searchQuery, 8);
searchResults = response.results;
showDropdown = searchResults.length > 0;
activeIndex = -1;
// Highlight matching nodes in Explorer graph
if (getCurrentRoute().page === 'explorer' && searchResults.length > 0) {
setHighlightedNodes(searchResults.map(r => r.entity_id));
} else {
setHighlightedNodes(null);
}
} catch {
searchResults = [];
showDropdown = false;
activeIndex = -1;
}
}, 300);
}
function selectResult(id: string) {
showDropdown = false;
searchQuery = '';
searchResults = [];
activeIndex = -1;
setHighlightedNodes(null);
const current = getCurrentRoute();
const from = current.page === 'entity' ? (current.from ?? current.page) : current.page;
navigate({ page: 'entity', id, from: from as 'explorer' | 'ontology' | 'dashboard' });
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, searchResults.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
selectResult(searchResults[activeIndex].entity_id);
} else if (e.key === 'Escape') {
showDropdown = false;
activeIndex = -1;
}
}
function closeDropdownOnFocusout(event: FocusEvent) {
if (event.relatedTarget && (event.relatedTarget as HTMLElement).closest('[data-search-dropdown]')) {
return;
}
showDropdown = false;
setHighlightedNodes(null);
}
function entityTypeIcon(type: string): string {
const map: Record<string, string> = {
pattern: 'design_services',
refactoring: 'transform',
law: 'gavel',
smell: 'warning',
insight: 'lightbulb',
};
return map[type] || 'circle';
}
</script>
<header class="flex justify-between items-center px-4 h-[var(--topbar-height)] border-b shrink-0
bg-[var(--color-surface)]/80 backdrop-blur-md border-[var(--color-outline-variant)]">
<div class="flex items-center gap-4">
{#if showSearch}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" role="combobox" aria-expanded={showDropdown} onkeydown={handleKeydown}>
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2
text-[var(--color-on-surface-variant)] pointer-events-none text-[18px]">search</span>
<input
id="global-search"
type="text"
bind:value={searchQuery}
oninput={handleSearchInput}
onblur={closeDropdownOnFocusout}
onfocus={() => { if (searchResults.length > 0) showDropdown = true; }}
placeholder="Search entities... (⌘K)"
class="bg-[var(--color-surface-container-low)] border border-[var(--color-outline-variant)]
rounded-lg pl-9 pr-10 py-1.5 text-sm w-72 outline-none
focus:ring-1 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]
text-[var(--color-on-surface)] placeholder:text-[var(--color-outline)]
transition-colors"
/>
<kbd class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-mono
text-[var(--color-outline)] border border-[var(--color-outline-variant)] px-1.5 py-0.5 rounded
bg-[var(--color-surface-container)]">⌘K</kbd>
{#if showDropdown && searchResults.length > 0}
<div data-search-dropdown class="absolute top-full left-0 mt-2 w-96 bg-[var(--color-surface)] border border-[var(--color-outline-variant)] shadow-xl z-50
max-h-80 overflow-y-auto rounded-lg">
{#each searchResults as result, i}
<button
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors
{i === activeIndex
? 'bg-[var(--color-primary)]/10'
: 'hover:bg-[var(--color-surface-container-high)]/50'}
border-b border-[var(--color-outline-variant)]/20 last:border-0"
onclick={() => selectResult(result.entity_id)}
onmouseenter={() => activeIndex = i}
>
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0"
style="background: color-mix(in srgb, var(--color-{result.type}) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--color-{result.type}) 25%, transparent);">
<span class="material-symbols-outlined text-[14px]"
style="color: var(--color-{result.type})">{entityTypeIcon(result.type)}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-[var(--color-on-surface)] truncate">{result.title}</p>
<p class="text-[10px] text-[var(--color-on-surface-variant)] font-mono">{result.entity_id}</p>
</div>
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded
text-[var(--color-on-surface-variant)] bg-[var(--color-surface-container-high)]/50">{result.type}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1 border-l border-[var(--color-outline-variant)] pl-3">
<div class="w-1.5 h-1.5 rounded-full
{status === 'connected' ? 'bg-[var(--color-rel-solves)]' : status === 'connecting' ? 'bg-[var(--color-law)]' : 'bg-[var(--color-error)]'}">
</div>
<span class="text-[10px] text-[var(--color-on-surface-variant)]">
{status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting...' : 'Offline'}
</span>
</div>
<button
onclick={toggle}
class="p-1.5 rounded-lg text-[var(--color-on-surface-variant)]
hover:text-[var(--color-primary)] hover:bg-[var(--color-surface-container-high)]/50 transition-colors"
>
<span class="material-symbols-outlined text-[18px]">
{isDark() ? 'light_mode' : 'dark_mode'}
</span>
</button>
</div>
</header>