596 lines
20 KiB
Svelte
596 lines
20 KiB
Svelte
<!-- #region TopNavbar [C:4] [TYPE Component] [SEMANTICS navbar, search, activity, user-menu, environment] -->
|
|
<!-- @ingroup Layout -->
|
|
<!-- @BRIEF Unified top navigation bar with Logo, global search, environment selector, activity indicator, user menu, and assistant toggle. -->
|
|
<!-- @LAYER UI -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:activityStore] -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:authStore] -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:sidebarStore] -->
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
|
|
<!-- @RELATION DISPATCHES -> [EXT:frontend:assistantChatStore] -->
|
|
<!-- @RELATION DEPENDS_ON -> [Icon] -->
|
|
<!-- @RELATION DEPENDS_ON -> [LanguageSwitcher] -->
|
|
<!-- @UX_STATE Idle -> Navbar showing current state with all controls. -->
|
|
<!-- @UX_STATE SearchFocused -> Search input expands with focus styling. -->
|
|
<!-- @UX_STATE Searching -> Search dropdown open while async results load. -->
|
|
<!-- @UX_FEEDBACK Activity badge shows count of running tasks. -->
|
|
<!-- @UX_FEEDBACK Environment selector shows PROD context in red. -->
|
|
<!-- @UX_RECOVERY Click outside closes dropdowns. -->
|
|
<script lang="ts">
|
|
/**
|
|
* @PURPOSE: Unified top navigation bar with Logo, Search, Activity, and User menu
|
|
* @LAYER UI
|
|
* @RELATION BINDS_TO -> activity
|
|
* @RELATION BINDS_TO -> auth
|
|
* @RELATION BINDS_TO -> environmentContext
|
|
* @RELATION DISPATCHES -> [EXT:frontend:taskDrawer]
|
|
* @RELATION DISPATCHES -> assistantChat
|
|
* @RELATION DEPENDS_ON -> Icon
|
|
* @RELATION DEPENDS_ON -> LanguageSwitcher
|
|
* @SEMANTICS: Navigation, UserSession
|
|
* @PRE: Auth, environment, and task stores are initialized before interactive navbar actions are used.
|
|
* @POST: Search, environment selection, and global action controls remain synchronized with current app state.
|
|
* @SIDE_EFFECT: Reads profile preferences, performs search API requests, and triggers drawer, auth, and assistant actions.
|
|
* @DATA_CONTRACT: SearchQuery -> SearchSection[] | ProfilePreferences -> TaskDrawerPreference
|
|
* @INVARIANT: Always visible on non-login pages
|
|
* @UX_REACTIVITY: Uses $state for menu/search state and $derived projections from app stores.
|
|
*
|
|
* @UX_STATE: Idle -> Navbar showing current state
|
|
* @UX_STATE: SearchFocused -> Search input expands
|
|
* @UX_STATE: Searching -> Search dropdown stays open while async results are loading.
|
|
* @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}
|
|
*
|
|
* @TEST_CONTRACT Component_TopNavbar ->
|
|
* {
|
|
* required_props: {},
|
|
* optional_props: {},
|
|
* invariants: [
|
|
* "Displays user menu and handles logical toggling",
|
|
* "Initiates global search successfully taking debounce into account",
|
|
* "Correctly handles activity notification badge visibility"
|
|
* ]
|
|
* }
|
|
* @TEST_FIXTURE logged_in -> {"user": {"username": "admin"}}
|
|
* @TEST_EDGE network_down -> search fetch fails, handles error state
|
|
* @TEST_INVARIANT ui_consistency -> verifies: [logged_in]
|
|
*/
|
|
|
|
import { onMount } from "svelte";
|
|
import { log } from "$lib/cot-logger";
|
|
import { goto } from "$app/navigation";
|
|
import { ROUTES } from "$lib/routes";
|
|
import { api } from "$lib/api.js";
|
|
import { getReports } from "$lib/api/reports.js";
|
|
import { activityStore } from "$lib/stores/activity.svelte.js";
|
|
import {
|
|
openDrawerForTask,
|
|
openDrawer,
|
|
setTaskDrawerAutoOpenPreference,
|
|
} from "$lib/stores/taskDrawer.svelte.js";
|
|
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.svelte.js";
|
|
import { t } from "$lib/i18n/index.svelte.js";
|
|
import { auth } from "$lib/auth/store.svelte.js";
|
|
import { hasPermission } from "$lib/auth/permissions.js";
|
|
import { toggleAssistantChat } from "$lib/stores/assistantChat.svelte.js";
|
|
import { Icon } from "$lib/ui";
|
|
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
|
|
import {
|
|
environmentContextStore,
|
|
initializeEnvironmentContext,
|
|
setSelectedEnvironment,
|
|
selectedEnvironmentStore,
|
|
} from "$lib/stores/environmentContext.svelte.js";
|
|
let showUserMenu = $state(false);
|
|
let isSearchFocused = $state(false);
|
|
let searchQuery = $state("");
|
|
let showSearchDropdown = $state(false);
|
|
let isSearchLoading = $state(false);
|
|
let groupedSearchResults = $state([]);
|
|
let searchTimer = null;
|
|
|
|
const SEARCH_DEBOUNCE_MS = 250;
|
|
const SEARCH_MIN_LENGTH = 2;
|
|
const SEARCH_LIMIT = 5;
|
|
|
|
let _authState = $state({ user: null, token: null, isAuthenticated: false, loading: true });
|
|
onMount(() => {
|
|
const unsub = auth.subscribe(v => { _authState = v; });
|
|
return unsub;
|
|
});
|
|
|
|
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
|
let activeCount = $derived(activityStore.value?.activeCount || 0);
|
|
let recentTasks = $derived(activityStore.value?.recentTasks || []);
|
|
let user = $derived(_authState.user || null);
|
|
let canOpenSettings = $derived(
|
|
hasPermission(user, "admin:settings", "READ"),
|
|
);
|
|
let globalEnvironments = $derived(
|
|
$environmentContextStore?.environments || [],
|
|
);
|
|
let globalSelectedEnvId = $derived(
|
|
$environmentContextStore?.selectedEnvId || "",
|
|
);
|
|
let globalSelectedEnv = $derived($selectedEnvironmentStore);
|
|
let selectedEnvironmentValue = $state("");
|
|
let isProdContext = $derived(
|
|
String(globalSelectedEnv?.stage || "").toUpperCase() === "PROD" ||
|
|
Boolean(globalSelectedEnv?.is_production),
|
|
);
|
|
|
|
$effect(() => {
|
|
selectedEnvironmentValue = globalSelectedEnvId;
|
|
});
|
|
|
|
function toggleUserMenu(event) {
|
|
event.stopPropagation();
|
|
showUserMenu = !showUserMenu;
|
|
}
|
|
|
|
function closeUserMenu() {
|
|
showUserMenu = false;
|
|
}
|
|
|
|
function handleLogout() {
|
|
auth.logout();
|
|
closeUserMenu();
|
|
window.location.href = ROUTES.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();
|
|
}
|
|
}
|
|
|
|
function handleAssistantClick() {
|
|
goto("/agent");
|
|
}
|
|
|
|
async function hydrateTaskDrawerPreference() {
|
|
try {
|
|
const response = await api.getProfilePreferences();
|
|
const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer;
|
|
setTaskDrawerAutoOpenPreference(autoOpenTaskDrawer !== false);
|
|
} catch (error) {
|
|
log("TopNavbar", "EXPLORE", "Failed to hydrate task drawer preference", {}, error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
function handleSearchFocus() {
|
|
isSearchFocused = true;
|
|
showSearchDropdown = groupedSearchResults.length > 0;
|
|
}
|
|
|
|
function clearSearchState() {
|
|
showSearchDropdown = false;
|
|
isSearchLoading = false;
|
|
groupedSearchResults = [];
|
|
}
|
|
|
|
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) {
|
|
const nextEnvId = event.currentTarget.value;
|
|
selectedEnvironmentValue = nextEnvId;
|
|
setSelectedEnvironment(nextEnvId);
|
|
if (searchQuery.trim().length >= SEARCH_MIN_LENGTH) {
|
|
triggerSearch(searchQuery.trim());
|
|
}
|
|
}
|
|
|
|
function buildSearchResultSections(
|
|
dashboardResponse,
|
|
datasetResponse,
|
|
tasksResponse,
|
|
reportsResponse,
|
|
query,
|
|
) {
|
|
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/${encodeURIComponent(String(dashboard.slug || 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)}`,
|
|
}));
|
|
const q = String(query || "").toLowerCase();
|
|
const tasks = (tasksResponse || []).slice(0, 30);
|
|
const taskItems = tasks
|
|
.filter((task) => {
|
|
const haystack =
|
|
`${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase();
|
|
return q && haystack.includes(q);
|
|
})
|
|
.slice(0, SEARCH_LIMIT)
|
|
.map((task) => ({
|
|
key: `task-${task.id}`,
|
|
type: "task",
|
|
title: task.plugin_id || "task",
|
|
subtitle: `${task.id} · ${task.status || "-"}`,
|
|
taskId: task.id,
|
|
}));
|
|
const reportItems = (reportsResponse?.items || [])
|
|
.slice(0, SEARCH_LIMIT)
|
|
.map((report) => ({
|
|
key: `report-${report.report_id}`,
|
|
type: "report",
|
|
title: report.summary || report.report_id,
|
|
subtitle: `${report.task_type || "-"} · ${report.status || "-"}`,
|
|
href: "/reports",
|
|
}));
|
|
|
|
const sections = [];
|
|
if (dashboardItems.length > 0) {
|
|
sections.push({
|
|
key: "dashboards",
|
|
label: $t.nav?.dashboards || "Dashboards",
|
|
items: dashboardItems,
|
|
});
|
|
}
|
|
if (datasetItems.length > 0) {
|
|
sections.push({
|
|
key: "datasets",
|
|
label: $t.nav?.datasets || "Datasets",
|
|
items: datasetItems,
|
|
});
|
|
}
|
|
if (taskItems.length > 0) {
|
|
sections.push({
|
|
key: "tasks",
|
|
label: $t.nav?.tasks || "Tasks",
|
|
items: taskItems,
|
|
});
|
|
}
|
|
if (reportItems.length > 0) {
|
|
sections.push({
|
|
key: "reports",
|
|
label: $t.nav?.reports || "Reports",
|
|
items: reportItems,
|
|
});
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
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,
|
|
tasksResponse,
|
|
reportsResponse,
|
|
] = 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,
|
|
}),
|
|
api.getTasks({ limit: 30, offset: 0, search: normalizedQuery }),
|
|
getReports({
|
|
page: 1,
|
|
page_size: SEARCH_LIMIT,
|
|
search: normalizedQuery,
|
|
sort_by: "updated_at",
|
|
sort_order: "desc",
|
|
}),
|
|
]);
|
|
groupedSearchResults = buildSearchResultSections(
|
|
dashboardResponse,
|
|
datasetResponse,
|
|
tasksResponse,
|
|
reportsResponse,
|
|
normalizedQuery,
|
|
);
|
|
} catch (error) {
|
|
log("TopNavbar", "EXPLORE", "Global search failed", { query: normalizedQuery }, error instanceof Error ? error.message : String(error));
|
|
groupedSearchResults = [];
|
|
} 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;
|
|
if (item.type === "task" && item.taskId) {
|
|
openDrawerForTask(item.taskId);
|
|
return;
|
|
}
|
|
if (item.href) {
|
|
await goto(item.href);
|
|
}
|
|
}
|
|
|
|
async function handleSearchKeydown(event) {
|
|
if (event.key === "Escape") {
|
|
clearSearchState();
|
|
searchQuery = "";
|
|
isSearchFocused = false;
|
|
return;
|
|
}
|
|
const firstItem = groupedSearchResults[0]?.items?.[0];
|
|
if (event.key === "Enter" && firstItem) {
|
|
event.preventDefault();
|
|
await openSearchResult(firstItem);
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
void initializeEnvironmentContext();
|
|
void hydrateTaskDrawerPreference();
|
|
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 max-w-full items-center justify-between overflow-hidden border-b border-border bg-surface-card px-3 shadow-sm sm:px-4
|
|
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
|
>
|
|
<!-- Left section: Hamburger (mobile) + Logo -->
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<!-- Hamburger Menu (mobile only) -->
|
|
<button
|
|
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted md:hidden"
|
|
onclick={handleHamburgerClick}
|
|
aria-label={$t.common?.toggle_menu}
|
|
>
|
|
<Icon name="menu" size={22} />
|
|
</button>
|
|
|
|
<!-- Logo/Brand -->
|
|
<a
|
|
href="/"
|
|
class="flex min-w-0 items-center text-xl font-bold text-text 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 class="hidden sm:inline">{$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
|
|
id="global-search"
|
|
name="global_search"
|
|
aria-label={$t.common.search}
|
|
type="text"
|
|
class="w-full px-4 py-2 bg-surface-muted rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
|
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
|
placeholder={$t.common.search}
|
|
value={searchQuery}
|
|
oninput={handleSearchInput}
|
|
onfocus={handleSearchFocus}
|
|
onkeydown={handleSearchKeydown}
|
|
/>
|
|
{#if showSearchDropdown}
|
|
<div
|
|
class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-border bg-surface-card shadow-lg"
|
|
>
|
|
{#if isSearchLoading}
|
|
<div class="px-4 py-3 text-sm text-text-muted">
|
|
{$t.common?.loading || "Loading..."}
|
|
</div>
|
|
{:else if groupedSearchResults.length === 0}
|
|
<div class="px-4 py-3 text-sm text-text-muted">
|
|
{$t.common?.not_found || "No results found"}
|
|
</div>
|
|
{:else}
|
|
{#each groupedSearchResults as section}
|
|
<div class="border-b border-border last:border-b-0">
|
|
<div
|
|
class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted"
|
|
>
|
|
{section.label}
|
|
</div>
|
|
{#each section.items as result}
|
|
<button
|
|
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-surface-page"
|
|
onclick={() => openSearchResult(result)}
|
|
>
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-medium text-text">
|
|
{result.title}
|
|
</div>
|
|
<div class="truncate text-xs text-text-muted">
|
|
{result.subtitle}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Nav Actions -->
|
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2 md:gap-4">
|
|
{#if globalEnvironments.length > 0}
|
|
<div class="hidden lg:flex items-center gap-2">
|
|
<select
|
|
id="global-environment-select"
|
|
name="global_environment"
|
|
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
|
|
{isProdContext
|
|
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'
|
|
: 'border-border-strong bg-surface-card text-text focus:ring-sky-200'}"
|
|
bind:value={selectedEnvironmentValue}
|
|
onchange={handleGlobalEnvironmentChange}
|
|
aria-label={$t.dashboard?.environment || "Environment"}
|
|
title={$t.dashboard?.environment || "Environment"}
|
|
>
|
|
{#each globalEnvironments as env}
|
|
<option value={env.id}>
|
|
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
|
|
</div>
|
|
{/if}
|
|
|
|
<LanguageSwitcher />
|
|
|
|
<!-- Assistant -->
|
|
<button
|
|
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted"
|
|
onclick={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-text-muted transition-colors hover:bg-surface-muted"
|
|
onclick={handleActivityClick}
|
|
onkeydown={(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"
|
|
onclick={toggleUserMenu}
|
|
onkeydown={(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-surface-card rounded-lg shadow-lg border border-border py-1 z-50 {showUserMenu
|
|
? ''
|
|
: 'hidden'}"
|
|
>
|
|
<div class="px-4 py-2 text-sm text-text">
|
|
<strong>{user?.username || $t.common?.user}</strong>
|
|
</div>
|
|
<div class="border-t border-border my-1"></div>
|
|
{#if canOpenSettings}
|
|
<div
|
|
class="px-4 py-2 text-sm text-text hover:bg-surface-muted cursor-pointer"
|
|
onclick={() => {
|
|
window.location.href = ROUTES.settings.general();
|
|
}}
|
|
onkeydown={(e) =>
|
|
(e.key === "Enter" || e.key === " ") &&
|
|
(window.location.href = ROUTES.settings.general())}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
{$t.nav?.settings}
|
|
</div>
|
|
{/if}
|
|
<div
|
|
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
|
|
onclick={handleLogout}
|
|
onkeydown={(e) =>
|
|
(e.key === "Enter" || e.key === " ") && handleLogout()}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
{$t.common?.logout}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- #endregion TopNavbar -->
|