From b7da4ab6b5cd058a3cca86ed30ac6b4b3492703c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 2 Apr 2026 15:29:38 +0300 Subject: [PATCH] feat: add Integrations tab and responsive icon-only tabs Move HA sources, weather sources, game integration, and MQTT settings into a dedicated Integrations top-level tab with tab-registry pattern. Collapse tab labels to icon-only at narrow desktop widths (<=1100px) to prevent toolbar overflow. --- .../src/wled_controller/static/css/layout.css | 26 +-- server/src/wled_controller/static/js/app.ts | 9 +- .../static/js/core/entity-events.ts | 10 + .../static/js/core/navigation.ts | 14 +- .../static/js/core/tab-registry.ts | 86 ++++++++ .../static/js/features/automations.ts | 11 +- .../static/js/features/color-strips.ts | 2 +- .../static/js/features/dashboard.ts | 7 +- .../static/js/features/game-integration.ts | 6 +- .../static/js/features/ha-light-targets.ts | 2 +- .../js/features/home-assistant-sources.ts | 5 +- .../static/js/features/integrations.ts | 187 ++++++++++++++++++ .../static/js/features/perf-charts.ts | 4 +- .../static/js/features/scene-presets.ts | 3 +- .../static/js/features/streams.ts | 71 +------ .../static/js/features/tabs.ts | 48 ++--- .../static/js/features/targets.ts | 7 +- .../static/js/features/value-sources.ts | 2 +- .../static/js/features/weather-sources.ts | 5 +- .../wled_controller/static/locales/en.json | 1 + .../wled_controller/static/locales/ru.json | 1 + .../wled_controller/static/locales/zh.json | 1 + .../src/wled_controller/templates/index.html | 12 +- 23 files changed, 369 insertions(+), 151 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/tab-registry.ts create mode 100644 server/src/wled_controller/static/js/features/integrations.ts diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index de69aaf..3f3ad82 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -770,6 +770,21 @@ h2 { text-align: center; } +@media (max-width: 1100px) { + .tab-btn { + padding: 10px 10px; + } + + .tab-btn > span[data-i18n] { + display: none; + } + + .tab-btn .icon { + width: 20px; + height: 20px; + } +} + @media (max-width: 900px) { header { flex-direction: column; @@ -781,14 +796,3 @@ h2 { padding: 12px; } } - -@media (max-width: 600px) { - .tab-bar { - flex-wrap: wrap; - } - - .tab-btn { - padding: 8px 12px; - font-size: 0.9rem; - } -} diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index da68e89..16ba3fa 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -92,6 +92,9 @@ import { openSetupInstructions, closeSetupInstructions, autoSetupGameIntegration, } from './features/game-integration.ts'; +import { + loadIntegrations, switchIntegrationTab, +} from './features/integrations.ts'; import { openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, activateScenePreset, cloneScenePreset, deleteScenePreset, @@ -403,6 +406,10 @@ Object.assign(window, { deleteScenePreset, addSceneTarget, + // integrations + loadIntegrations, + switchIntegrationTab, + // game integration showGameIntegrationEditor, saveGameIntegration, @@ -632,7 +639,7 @@ document.addEventListener('keydown', (e) => { // Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { - const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'graph' }; + const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' }; const tab = tabMap[e.key]; if (tab) { e.preventDefault(); diff --git a/server/src/wled_controller/static/js/core/entity-events.ts b/server/src/wled_controller/static/js/core/entity-events.ts index b4d07f3..e0223f5 100644 --- a/server/src/wled_controller/static/js/core/entity-events.ts +++ b/server/src/wled_controller/static/js/core/entity-events.ts @@ -11,6 +11,8 @@ import { syncClocksCache, automationsCacheObj, scenePresetsCache, captureTemplatesCache, audioTemplatesCache, ppTemplatesCache, patternTemplatesCache, + weatherSourcesCache, haSourcesCache, mqttSourcesCache, + gameIntegrationsCache, } from './state.ts'; /** Maps entity_type string from the server event to its DataCache instance. */ @@ -28,6 +30,10 @@ const ENTITY_CACHE_MAP = { audio_template: audioTemplatesCache, pp_template: ppTemplatesCache, pattern_template: patternTemplatesCache, + weather_source: weatherSourcesCache, + home_assistant_source: haSourcesCache, + mqtt_source: mqttSourcesCache, + game_integration: gameIntegrationsCache, }; /** Maps entity_type to the window load function that refreshes its UI. */ @@ -45,6 +51,10 @@ const ENTITY_LOADER_MAP = { pp_template: 'loadPictureSources', automation: 'loadAutomations', scene_preset: 'loadAutomations', + weather_source: 'loadIntegrations', + home_assistant_source: 'loadIntegrations', + mqtt_source: 'loadIntegrations', + game_integration: 'loadIntegrations', }; /** Debounce timers per loader function name — coalesces rapid WS events and diff --git a/server/src/wled_controller/static/js/core/navigation.ts b/server/src/wled_controller/static/js/core/navigation.ts index 731d447..e0918e0 100644 --- a/server/src/wled_controller/static/js/core/navigation.ts +++ b/server/src/wled_controller/static/js/core/navigation.ts @@ -3,6 +3,7 @@ */ import { switchTab } from '../features/tabs.ts'; +import { callSubTabSwitcher, callTabLoader } from './tab-registry.ts'; /** * Navigate to a card on any tab/subtab, expanding the section and scrolling to it. @@ -25,13 +26,7 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s requestAnimationFrame(() => { if (subTab) { - if (tab === 'targets' && typeof window.switchTargetSubTab === 'function') { - window.switchTargetSubTab(subTab); - } else if (tab === 'streams' && typeof window.switchStreamTab === 'function') { - window.switchStreamTab(subTab); - } else if (tab === 'automations' && typeof window.switchAutomationTab === 'function') { - window.switchAutomationTab(subTab); - } + callSubTabSwitcher(tab, subTab); } // Expand section if collapsed @@ -89,10 +84,7 @@ function _highlightCard(card: Element) { /** Trigger the tab's data load function (used when card wasn't found in DOM). */ function _triggerTabLoad(tab: string) { - if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard(); - else if (tab === 'automations' && typeof window.loadAutomations === 'function') window.loadAutomations(); - else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources(); - else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + callTabLoader(tab); } function _showDimOverlay(duration: number) { diff --git a/server/src/wled_controller/static/js/core/tab-registry.ts b/server/src/wled_controller/static/js/core/tab-registry.ts new file mode 100644 index 0000000..c63dd74 --- /dev/null +++ b/server/src/wled_controller/static/js/core/tab-registry.ts @@ -0,0 +1,86 @@ +/** + * Tab Registry — single source of truth for tab metadata, subtab storage keys, + * loader/switcher function names, and active-tab queries. + * + * Eliminates scattered if-chains that compare tab IDs as string literals. + */ + +interface SubTabConfig { + storageKey: string; + defaultSubTab: string; + switchFnName: string; +} + +interface TabConfig { + loadFnName: string; + subTab?: SubTabConfig; + autoRefresh?: boolean; +} + +const TAB_REGISTRY: Readonly> = { + dashboard: { loadFnName: 'loadDashboard', autoRefresh: true }, + targets: { loadFnName: 'loadTargetsTab', autoRefresh: true, + subTab: { storageKey: 'activeTargetSubTab', defaultSubTab: 'led-devices', switchFnName: 'switchTargetSubTab' } }, + streams: { loadFnName: 'loadPictureSources', + subTab: { storageKey: 'activeStreamTab', defaultSubTab: 'raw', switchFnName: 'switchStreamTab' } }, + integrations: { loadFnName: 'loadIntegrations', + subTab: { storageKey: 'activeIntegrationTab', defaultSubTab: 'weather', switchFnName: 'switchIntegrationTab' } }, + automations: { loadFnName: 'loadAutomations', + subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } }, + graph: { loadFnName: 'loadGraphEditor' }, +}; + +/** Get the full config for a tab, or undefined if not registered. */ +export function getTabConfig(tab: string): TabConfig | undefined { + return TAB_REGISTRY[tab]; +} + +/** Get the subtab config for a tab, or undefined if the tab has no subtabs. */ +export function getSubTabConfig(tab: string): SubTabConfig | undefined { + return TAB_REGISTRY[tab]?.subTab; +} + +/** Read the active subtab from localStorage (with default fallback), or null if tab has no subtabs. */ +export function getActiveSubTab(tab: string): string | null { + const cfg = TAB_REGISTRY[tab]?.subTab; + if (!cfg) return null; + return localStorage.getItem(cfg.storageKey) || cfg.defaultSubTab; +} + +/** Write the active subtab to localStorage. No-op if tab has no subtabs. */ +export function setActiveSubTab(tab: string, subTab: string): void { + const cfg = TAB_REGISTRY[tab]?.subTab; + if (cfg) localStorage.setItem(cfg.storageKey, subTab); +} + +/** Call the tab's loader function via window. Returns true if the function existed and was called. */ +export function callTabLoader(tab: string): boolean { + const fnName = TAB_REGISTRY[tab]?.loadFnName; + if (fnName && typeof (window as any)[fnName] === 'function') { + (window as any)[fnName](); + return true; + } + return false; +} + +/** Call the tab's subtab switcher function via window. Returns true if the function existed and was called. */ +export function callSubTabSwitcher(tab: string, subTab: string): boolean { + const fnName = TAB_REGISTRY[tab]?.subTab?.switchFnName; + if (fnName && typeof (window as any)[fnName] === 'function') { + (window as any)[fnName](subTab); + return true; + } + return false; +} + +/** Check whether the given tab is currently active. */ +export function isActiveTab(tab: string): boolean { + return (localStorage.getItem('activeTab') || 'dashboard') === tab; +} + +/** Get all tab names that have autoRefresh enabled. */ +export function getAutoRefreshTabs(): string[] { + return Object.entries(TAB_REGISTRY) + .filter(([, cfg]) => cfg.autoRefresh) + .map(([name]) => name); +} diff --git a/server/src/wled_controller/static/js/features/automations.ts b/server/src/wled_controller/static/js/features/automations.ts index 0f7e27e..18f35c6 100644 --- a/server/src/wled_controller/static/js/features/automations.ts +++ b/server/src/wled_controller/static/js/features/automations.ts @@ -10,6 +10,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { CardSection } from '../core/card-sections.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts'; +import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; @@ -154,7 +155,7 @@ export function switchAutomationTab(tabKey: string) { document.querySelectorAll('.automation-sub-tab-panel').forEach(panel => (panel as HTMLElement).classList.toggle('active', panel.id === `automation-tab-${tabKey}`) ); - localStorage.setItem('activeAutomationTab', tabKey); + setActiveSubTab('automations', tabKey); updateSubTabHash('automations', tabKey); if (!_automationsTreeTriggered) { _automationsTree.setActive(tabKey); @@ -180,14 +181,12 @@ function _ensureRuleLogicIconSelect() { // Re-render automations when language changes (only if tab is active) document.addEventListener('languageChanged', () => { - if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') loadAutomations(); + if (apiKey && isActiveTab('automations')) loadAutomations(); }); // React to real-time automation state changes from global events WS document.addEventListener('server:automation_state_changed', () => { - if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') { - loadAutomations(); - } + if (apiKey && isActiveTab('automations')) loadAutomations(); }); export async function loadAutomations() { @@ -223,7 +222,7 @@ function renderAutomations(automations: any, sceneMap: any) { const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) }))); const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) }))); - const activeTab = localStorage.getItem('activeAutomationTab') || 'automations'; + const activeTab = getActiveSubTab('automations')!; const treeItems = [ { key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length }, diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index 73da41c..dc14e1b 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -1752,7 +1752,7 @@ const CSS_CARD_RENDERERS: Record = { const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id); const wsName = ws?.name || '—'; const wsLink = ws - ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','weather','weather-sources','data-id','${source.weather_source_id}')` + ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('integrations','weather','weather-sources','data-id','${source.weather_source_id}')` : ''; return ` ${ICON_LINK_SOURCE} ${escapeHtml(wsName)} diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index b3a16e2..50a3d6b 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -8,6 +8,7 @@ import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; +import { isActiveTab } from '../core/tab-registry.ts'; import { ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, @@ -266,7 +267,7 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string { ? `${conn.entity_count} ${t('dashboard.integrations.entities')}` : t('ha_source.disconnected'); - return `