230 lines
7.1 KiB
Svelte
230 lines
7.1 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 } from "svelte";
|
|
import { page } from "$app/stores";
|
|
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";
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
let showUserMenu = false;
|
|
let isSearchFocused = false;
|
|
|
|
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
|
$: activeCount = $activityStore?.activeCount || 0;
|
|
$: recentTasks = $activityStore?.recentTasks || [];
|
|
$: user = $auth?.user || null;
|
|
|
|
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;
|
|
}
|
|
|
|
function handleSearchBlur() {
|
|
isSearchFocused = false;
|
|
}
|
|
|
|
function handleDocumentClick(event) {
|
|
if (!event.target.closest(".user-menu-container")) {
|
|
closeUserMenu();
|
|
}
|
|
}
|
|
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("click", handleDocumentClick);
|
|
}
|
|
|
|
function handleHamburgerClick(event) {
|
|
event.stopPropagation();
|
|
toggleMobileSidebar();
|
|
}
|
|
</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 || "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>Superset Tools</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Search placeholder (non-functional for now) -->
|
|
<div class="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 || "Search..."}
|
|
on:focus={handleSearchFocus}
|
|
on:blur={handleSearchBlur}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Nav Actions -->
|
|
<div class="flex items-center gap-3 md:gap-4">
|
|
<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 || "Open assistant"}
|
|
title={$t.assistant?.title || "AI Assistant"}
|
|
>
|
|
<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 || "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 || "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 || "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 || "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 || "Logout"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- [/DEF:TopNavbar:Component] -->
|