Files
ss-tools/frontend/src/lib/components/layout/TopNavbar.svelte

411 lines
13 KiB
Svelte

<!-- [DEF:TopNavbar:Component] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Unified top navigation bar with Logo, Search, Activity, and User menu
* @LAYER: UI
* @RELATION: BINDS_TO -> activityStore, authStore
* @SEMANTICS: Navigation, UserSession
* @INVARIANT: Always visible on non-login pages
*
* @UX_STATE: Idle -> Navbar showing current state
* @UX_STATE: SearchFocused -> Search input expands
* @UX_FEEDBACK: Activity badge shows count of running tasks
* @UX_RECOVERY: Click outside closes dropdowns
* @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied}
* @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens}
*/
import { createEventDispatcher, onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { api } from "$lib/api.js";
import { activityStore } from "$lib/stores/activity.js";
import {
taskDrawerStore,
openDrawerForTask,
openDrawer,
} from "$lib/stores/taskDrawer.js";
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js";
import { t } from "$lib/i18n";
import { auth } from "$lib/auth/store.js";
import { toggleAssistantChat } from "$lib/stores/assistantChat.js";
import Icon from "$lib/ui/Icon.svelte";
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
import {
environmentContextStore,
initializeEnvironmentContext,
setSelectedEnvironment,
selectedEnvironmentStore,
} from "$lib/stores/environmentContext.js";
const dispatch = createEventDispatcher();
let showUserMenu = false;
let isSearchFocused = false;
let searchQuery = "";
let showSearchDropdown = false;
let isSearchLoading = false;
let searchResults = [];
let searchTimer = null;
const SEARCH_DEBOUNCE_MS = 250;
const SEARCH_MIN_LENGTH = 2;
const SEARCH_LIMIT = 5;
$: isExpanded = $sidebarStore?.isExpanded ?? true;
$: activeCount = $activityStore?.activeCount || 0;
$: recentTasks = $activityStore?.recentTasks || [];
$: user = $auth?.user || null;
$: globalEnvironments = $environmentContextStore?.environments || [];
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
$: globalSelectedEnv = $selectedEnvironmentStore;
$: isProdContext = Boolean(globalSelectedEnv?.is_production);
function toggleUserMenu(event) {
event.stopPropagation();
showUserMenu = !showUserMenu;
}
function closeUserMenu() {
showUserMenu = false;
}
function handleLogout() {
auth.logout();
closeUserMenu();
window.location.href = "/login";
}
function handleActivityClick() {
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
if (runningTask) {
openDrawerForTask(runningTask.taskId);
} else if (recentTasks.length > 0) {
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
} else {
openDrawer();
}
dispatch("activityClick");
}
function handleAssistantClick() {
toggleAssistantChat();
}
function handleSearchFocus() {
isSearchFocused = true;
showSearchDropdown = searchResults.length > 0;
}
function clearSearchState() {
showSearchDropdown = false;
isSearchLoading = false;
searchResults = [];
}
function handleDocumentClick(event) {
if (!event.target.closest(".user-menu-container")) {
closeUserMenu();
}
if (!event.target.closest(".global-search-container")) {
isSearchFocused = false;
clearSearchState();
}
}
function handleHamburgerClick(event) {
event.stopPropagation();
toggleMobileSidebar();
}
function handleGlobalEnvironmentChange(event) {
setSelectedEnvironment(event.target.value);
if (searchQuery.trim().length >= SEARCH_MIN_LENGTH) {
triggerSearch(searchQuery.trim());
}
}
function buildSearchResultItems(dashboardResponse, datasetResponse) {
const dashboards = (dashboardResponse?.dashboards || []).slice(
0,
SEARCH_LIMIT,
);
const datasets = (datasetResponse?.datasets || []).slice(0, SEARCH_LIMIT);
const dashboardItems = dashboards.map((dashboard) => ({
key: `dashboard-${dashboard.id}`,
type: "dashboard",
title: dashboard.title || dashboard.dashboard_title || `#${dashboard.id}`,
subtitle: `ID: ${dashboard.id}`,
href: `/dashboards/${dashboard.id}?env_id=${encodeURIComponent(globalSelectedEnvId)}`,
}));
const datasetItems = datasets.map((dataset) => ({
key: `dataset-${dataset.id}`,
type: "dataset",
title: dataset.table_name || `#${dataset.id}`,
subtitle: dataset.schema || "-",
href: `/datasets/${dataset.id}?env_id=${encodeURIComponent(globalSelectedEnvId)}`,
}));
return [...dashboardItems, ...datasetItems];
}
async function triggerSearch(query) {
const normalizedQuery = String(query || "").trim();
if (normalizedQuery.length < SEARCH_MIN_LENGTH || !globalSelectedEnvId) {
clearSearchState();
return;
}
isSearchLoading = true;
showSearchDropdown = true;
try {
const [dashboardResponse, datasetResponse] = await Promise.all([
api.getDashboards(globalSelectedEnvId, {
search: normalizedQuery,
page: 1,
page_size: SEARCH_LIMIT,
}),
api.getDatasets(globalSelectedEnvId, {
search: normalizedQuery,
page: 1,
page_size: SEARCH_LIMIT,
}),
]);
searchResults = buildSearchResultItems(dashboardResponse, datasetResponse);
} catch (error) {
console.error("[TopNavbar][Coherence:Failed] Global search failed", error);
searchResults = [];
} finally {
isSearchLoading = false;
}
}
function handleSearchInput(event) {
searchQuery = event.target.value;
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
triggerSearch(searchQuery);
}, SEARCH_DEBOUNCE_MS);
}
async function openSearchResult(item) {
clearSearchState();
searchQuery = "";
isSearchFocused = false;
await goto(item.href);
}
async function handleSearchKeydown(event) {
if (event.key === "Escape") {
clearSearchState();
searchQuery = "";
isSearchFocused = false;
return;
}
if (event.key === "Enter" && searchResults.length > 0) {
event.preventDefault();
await openSearchResult(searchResults[0]);
}
}
onMount(async () => {
await initializeEnvironmentContext();
if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick);
}
return () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
if (typeof document !== "undefined") {
document.removeEventListener("click", handleDocumentClick);
}
};
});
</script>
<nav
class="fixed left-0 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 shadow-sm
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
>
<!-- Left section: Hamburger (mobile) + Logo -->
<div class="flex items-center gap-2">
<!-- Hamburger Menu (mobile only) -->
<button
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 md:hidden"
on:click={handleHamburgerClick}
aria-label={$t.common?.toggle_menu }
>
<Icon name="menu" size={22} />
</button>
<!-- Logo/Brand -->
<a
href="/"
class="flex items-center text-xl font-bold text-slate-800 transition-colors hover:text-primary"
>
<span class="mr-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 via-cyan-500 to-indigo-600 text-white shadow-sm">
<Icon name="layers" size={18} strokeWidth={2.1} />
</span>
<span>{$t.common?.brand }</span>
</a>
</div>
<!-- Global search -->
<div class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block">
<input
type="text"
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
placeholder={$t.common.search }
value={searchQuery}
on:input={handleSearchInput}
on:focus={handleSearchFocus}
on:keydown={handleSearchKeydown}
/>
{#if showSearchDropdown}
<div class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-slate-200 bg-white shadow-lg">
{#if isSearchLoading}
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.loading || "Loading..."}</div>
{:else if searchResults.length === 0}
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.not_found || "No results found"}</div>
{:else}
{#each searchResults as result}
<button
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-slate-50"
on:click={() => openSearchResult(result)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-800">{result.title}</div>
<div class="truncate text-xs text-slate-500">{result.subtitle}</div>
</div>
<span class="ml-4 rounded border border-slate-200 px-2 py-0.5 text-xs text-slate-600">
{result.type === "dashboard" ? ($t.nav?.dashboards || "Dashboards") : ($t.nav?.datasets || "Datasets")}
</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Nav Actions -->
<div class="flex items-center gap-3 md:gap-4">
{#if globalEnvironments.length > 0}
<div class="hidden lg:flex items-center gap-2">
<select
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
{isProdContext
? 'border-red-300 bg-red-50 text-red-900 focus:ring-red-200'
: 'border-slate-300 bg-white text-slate-700 focus:ring-sky-200'}"
value={globalSelectedEnvId}
on:change={handleGlobalEnvironmentChange}
aria-label={$t.dashboard?.environment || "Environment"}
title={$t.dashboard?.environment || "Environment"}
>
{#each globalEnvironments as env}
<option value={env.id}>
{env.name}{env.is_production ? " [PROD]" : ""}
</option>
{/each}
</select>
{#if isProdContext}
<span class="rounded-md bg-red-600 px-2 py-1 text-xs font-bold text-white">
PROD
</span>
{/if}
</div>
{/if}
<LanguageSwitcher />
<!-- Assistant -->
<button
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
on:click={handleAssistantClick}
aria-label={$t.assistant?.open }
title={$t.assistant?.title }
>
<Icon name="clipboard" size={22} />
</button>
<!-- Activity Indicator -->
<div
class="relative cursor-pointer rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
on:click={handleActivityClick}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
role="button"
tabindex="0"
aria-label={$t.common?.activity }
>
<Icon name="activity" size={22} />
{#if activeCount > 0}
<span
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
>{activeCount}</span
>
{/if}
</div>
<!-- User Menu -->
<div class="user-menu-container relative">
<div
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
on:click={toggleUserMenu}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
role="button"
tabindex="0"
aria-label={$t.common?.user_menu }
>
{#if user}
<span
>{user.username ? user.username.charAt(0).toUpperCase() : "U"}</span
>
{:else}
<span>U</span>
{/if}
</div>
<!-- User Dropdown -->
<div
class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 {showUserMenu
? ''
: 'hidden'}"
>
<div class="px-4 py-2 text-sm text-gray-700">
<strong>{user?.username || ($t.common?.user )}</strong>
</div>
<div class="border-t border-gray-200 my-1"></div>
<div
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => {
window.location.href = "/settings";
}}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(window.location.href = "/settings")}
role="button"
tabindex="0"
>
{$t.nav?.settings }
</div>
<div
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
on:click={handleLogout}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleLogout()}
role="button"
tabindex="0"
>
{$t.common?.logout }
</div>
</div>
</div>
</div>
</nav>
<!-- [/DEF:TopNavbar:Component] -->