diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index 7be029ba..1d1ef00d 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -2,105 +2,70 @@ + + + + + - + + + + + + + + - @@ -417,46 +200,44 @@ - + model.handleSearchInput(e.currentTarget.value)} + onfocus={() => model.handleSearchFocus()} onkeydown={handleSearchKeydown} /> - {#if showSearchDropdown} + {#if model.showSearchDropdown} - {#if isSearchLoading} + {#if model.isSearchLoading} {$t.common?.loading || "Loading..."} - {:else if groupedSearchResults.length === 0} + {:else if model.groupedSearchResults.length === 0} {$t.common?.not_found || "No results found"} {:else} - {#each groupedSearchResults as section} + {#each model.groupedSearchResults as section (section.key)} - {section.label} + {$t.nav?.[section.labelKey] || section.labelKey} - {#each section.items as result} + {#each section.items as result (result.key)} openSearchResult(result)} + onclick={() => model.openSearchResult(result)} > @@ -485,24 +266,21 @@ 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'}" + : 'border-border-strong bg-surface-card text-text focus:ring-primary-ring-light'}" bind:value={selectedEnvironmentValue} onchange={handleGlobalEnvironmentChange} aria-label={$t.dashboard?.environment || "Environment"} title={$t.dashboard?.environment || "Environment"} > - {#each globalEnvironments as env} + {#each globalEnvironments as env (env.id)} {env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""} {/each} - {/if} - - - {$t.assistant?.assistant || "Ассистент"} + {$t.assistant.assistant} @@ -539,7 +317,7 @@ 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)} + (e.key === "Enter" || e.key === " ") && toggleUserMenu()} role="button" tabindex="0" aria-label={$t.common?.user_menu} @@ -563,15 +341,30 @@ {user?.username || $t.common?.user} + { + closeUserMenu(); + window.location.href = ROUTES.profile(); + }} + onkeydown={(e) => + (e.key === "Enter" || e.key === " ") && + (closeUserMenu(), window.location.href = ROUTES.profile())} + role="button" + tabindex="0" + > + {$t.nav?.profile || "Profile"} + {#if canOpenSettings} { + closeUserMenu(); window.location.href = ROUTES.settings.general(); }} onkeydown={(e) => (e.key === "Enter" || e.key === " ") && - (window.location.href = ROUTES.settings.general())} + (closeUserMenu(), window.location.href = ROUTES.settings.general())} role="button" tabindex="0" > diff --git a/frontend/src/lib/models/TopNavbarModel.svelte.ts b/frontend/src/lib/models/TopNavbarModel.svelte.ts new file mode 100644 index 00000000..f2d5e7b4 --- /dev/null +++ b/frontend/src/lib/models/TopNavbarModel.svelte.ts @@ -0,0 +1,303 @@ +// #region TopNavbar.Model [C:4] [TYPE Model] [SEMANTICS navbar,search,model,topnav] +// @defgroup TopNavbar Screen Model for the unified top navigation bar search and drawer preference hydration. +// @LAYER Store +// @PRE Environment context store is loaded before search is triggered. +// @INVARIANT Search debounce at 250ms. Min query length 2 characters. Max 5 results per section. +// @INVARIANT ClearSearch resets all search state atoms to defaults. +// @STATE searchQuery, isSearchFocused, showSearchDropdown, isSearchLoading, groupedSearchResults +// @ACTION triggerSearch(query, envId), handleSearchInput(value), handleSearchFocus, handleSearchKeydown(key), openSearchResult(item), handleDocumentClick(event), clearSearch, hydrateTaskDrawerPreference, destroy +// @RELATION CALLS -> [getDashboards] +// @RELATION CALLS -> [getDatasets] +// @RELATION CALLS -> [getTasks] +// @RELATION CALLS -> [getProfilePreferences] +// @RELATION CALLS -> [getReports] +// @RELATION DISPATCHES -> [EXT:frontend:taskDrawerStore] +// @DATA_CONTRACT SearchQuery:string × EnvId:string → SearchSection[] | ProfilePreferences → void +// @POST Search results grouped by section (dashboards, datasets, tasks, reports). Dropdown stays open while results load. +// @SIDE_EFFECT Fetch API (dashboards, datasets, tasks, reports), localStorage I/O via setTaskDrawerAutoOpenPreference. +// @RATIONALE Search logic extracted from TopNavbar component (605 lines → ~300 lines) to satisfy INV_7 (module < 400 LOC) and ATTN_4 (contract ≤ 150 lines in sliding window). The original inline architecture scattered search state across 7 reactive atoms and 8 functions inside a single 605-line component — the model could not physically see the full contract under CSA/HCA compression. Model-first extraction puts dense #region anchor on line 1 for maximum first-line density, dot-separated hierarchical ID for HCA survival, and groups all search state into one unit that fits the sliding window. +// @REJECTED Extracting user menu, environment selector, and activity indicator into submodels was rejected — each is ≤ 3 store-derived values with simple toggle logic, extract overhead exceeds benefit. Inline $state for those three concerns is the right call per Model Decomposition Gate: they are local UI state without cross-widget invariants. Full decomposition into 3 submodels was rejected as premature (each submodel would be < 30 lines). + +import { goto } from "$app/navigation"; +import { log } from "$lib/cot-logger"; +import { api } from "$lib/api.js"; +import { getReports } from "$lib/api/reports.js"; +import { + openDrawerForTask, + setTaskDrawerAutoOpenPreference, +} from "$lib/stores/taskDrawer.svelte.js"; +import type { Task, TaskStatus } from "$types/models"; +import type { ReportCollection } from "$types/reports"; + +// ── Type definitions ───────────────────────────────────────────── + +interface SearchResultItem { + key: string; + type: string; + title: string; + subtitle: string; + href?: string; + taskId?: string; +} + +interface SearchSection { + key: string; + labelKey: string; // i18n key resolved in template: $t.nav?.[section.labelKey] + items: SearchResultItem[]; +} + +/** Dashboard shape returned by GET /dashboards (subset used by search). */ +interface DashboardSearchResult { + id: number | string; + title?: string; + dashboard_title?: string; + slug?: string; +} + +/** Dataset shape returned by GET /datasets (subset used by search). */ +interface DatasetSearchResult { + id: number | string; + table_name?: string; + schema?: string; +} + +/** Task enriched with fields used for client-side filtering. */ +interface TaskSearchResult extends Task { + plugin_id?: string; + status?: TaskStatus; +} + +interface DashboardsResponse { + dashboards?: DashboardSearchResult[]; +} + +interface DatasetsResponse { + datasets?: DatasetSearchResult[]; +} + +// ── Constants ──────────────────────────────────────────────────── + +const SEARCH_DEBOUNCE_MS = 250; +const SEARCH_MIN_LENGTH = 2; +const SEARCH_LIMIT = 5; + +// ── Model ──────────────────────────────────────────────────────── + +export class TopNavbarModel { + // ── Atoms ───────────────────────────────────────────────────── + searchQuery: string = $state(""); + isSearchFocused: boolean = $state(false); + showSearchDropdown: boolean = $state(false); + isSearchLoading: boolean = $state(false); + groupedSearchResults: SearchSection[] = $state([]); + + // ── Internal (non-reactive) ─────────────────────────────────── + private searchTimer: ReturnType | null = null; + + // ── Public actions ──────────────────────────────────────────── + + handleSearchInput(value: string): void { + this.searchQuery = value; + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + this.searchTimer = setTimeout(() => { + this._triggerSearch(this.searchQuery); + }, SEARCH_DEBOUNCE_MS); + } + + handleSearchFocus(): void { + this.isSearchFocused = true; + this.showSearchDropdown = this.groupedSearchResults.length > 0; + } + + clearSearch(): void { + this.showSearchDropdown = false; + this.isSearchLoading = false; + this.groupedSearchResults = []; + } + + async handleSearchKeydown(key: string, envId: string): Promise { + if (key === "Escape") { + this.clearSearch(); + this.searchQuery = ""; + this.isSearchFocused = false; + return true; + } + const firstItem = this.groupedSearchResults[0]?.items?.[0]; + if (key === "Enter" && firstItem) { + await this.openSearchResult(firstItem); + return true; + } + return false; + } + + async openSearchResult(item: SearchResultItem): Promise { + this.clearSearch(); + this.searchQuery = ""; + this.isSearchFocused = false; + if (item.type === "task" && item.taskId) { + openDrawerForTask(item.taskId); + return; + } + if (item.href) { + await goto(item.href); + } + } + + async triggerSearch(query: string, envId: string): Promise { + this.searchQuery = query; + await this._triggerSearch(query, envId); + } + + handleDocumentClick(event: MouseEvent): { closeUserMenu: boolean; closeSearch: boolean } { + const result = { closeUserMenu: false, closeSearch: false }; + if (!(event.target as Element)?.closest(".user-menu-container")) { + result.closeUserMenu = true; + } + if (!(event.target as Element)?.closest(".global-search-container")) { + result.closeSearch = true; + } + return result; + } + + async hydrateTaskDrawerPreference(): Promise { + try { + const response = await api.getProfilePreferences<{ preference?: { auto_open_task_drawer?: boolean } }>(); + const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer; + setTaskDrawerAutoOpenPreference(autoOpenTaskDrawer !== false); + } catch (error) { + log("TopNavbarModel", "EXPLORE", "Failed to hydrate task drawer preference", {}, error instanceof Error ? error.message : String(error)); + } + } + + destroy(): void { + if (this.searchTimer) { + clearTimeout(this.searchTimer); + this.searchTimer = null; + } + } + + // ── Private ─────────────────────────────────────────────────── + + private async _triggerSearch(query: string, envId?: string): Promise { + const normalizedQuery = String(query || "").trim(); + if (normalizedQuery.length < SEARCH_MIN_LENGTH || !envId) { + this.clearSearch(); + return; + } + this.isSearchLoading = true; + this.showSearchDropdown = true; + try { + const [ + dashboardResponse, + datasetResponse, + tasksResponse, + reportsResponse, + ] = await Promise.all([ + api.getDashboards(envId, { + search: normalizedQuery, + page: 1, + page_size: SEARCH_LIMIT, + }), + api.getDatasets(envId, { + 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", + }), + ]); + this.groupedSearchResults = this._buildSections( + dashboardResponse, + datasetResponse, + tasksResponse, + reportsResponse, + normalizedQuery, + envId, + ); + } catch (error) { + log("TopNavbarModel", "EXPLORE", "Global search failed", { query: normalizedQuery }, error instanceof Error ? error.message : String(error)); + this.groupedSearchResults = []; + } finally { + this.isSearchLoading = false; + } + } + + private _buildSections( + dashboardResponse: DashboardsResponse, + datasetResponse: DatasetsResponse, + tasksResponse: TaskSearchResult[], + reportsResponse: ReportCollection, + query: string, + envId: string, + ): SearchSection[] { + const dashboards = (dashboardResponse.dashboards || []).slice(0, SEARCH_LIMIT); + const datasets = (datasetResponse.datasets || []).slice(0, SEARCH_LIMIT); + + const dashboardItems: SearchResultItem[] = 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(envId)}`, + })); + + const datasetItems: SearchResultItem[] = 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(envId)}`, + })); + + const q = String(query || "").toLowerCase(); + const tasks = (tasksResponse || []).slice(0, 30); + const taskItems: SearchResultItem[] = 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: SearchResultItem[] = (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", + })); + + // Section labelKey resolves via $t.nav?.[labelKey] in the component template. + const sections: SearchSection[] = []; + if (dashboardItems.length > 0) { + sections.push({ key: "dashboards", labelKey: "dashboards", items: dashboardItems }); + } + if (datasetItems.length > 0) { + sections.push({ key: "datasets", labelKey: "datasets", items: datasetItems }); + } + if (taskItems.length > 0) { + sections.push({ key: "tasks", labelKey: "tasks", items: taskItems }); + } + if (reportItems.length > 0) { + sections.push({ key: "reports", labelKey: "reports", items: reportItems }); + } + return sections; + } +} +// #endregion TopNavbar.Model diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 4dd81420..05a5a9d1 100755 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -12,6 +12,7 @@ export default { DEFAULT: '#2563eb', // blue-600 hover: '#1d4ed8', // blue-700 ring: '#3b82f6', // blue-500 + 'ring-light': '#bae6fd', // sky-200 — focus ring for non-critical controls light: '#eff6ff', // blue-50 }, secondary: { @@ -96,6 +97,12 @@ export default { git: '#fb923c', system: '#38bdf8', }, + // ── Brand identity ────────────────────────────────────── + brand: { + 'gradient-from': '#0ea5e9', // sky-500 + 'gradient-via': '#06b6d4', // cyan-500 + 'gradient-to': '#4f46e5', // indigo-600 + }, // ── Category gradient tokens (sidebar/breadcrumb icons) ── category: { dashboards: { from: '#e0f2fe', to: '#bae6fd', text: '#0369a1', ring: '#7dd3fc' }, // sky