feat: add Integrations tab and responsive icon-only tabs
Lint & Test / test (push) Successful in 1m48s
Lint & Test / test (push) Successful in 1m48s
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Record<string, TabConfig>> = {
|
||||
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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -1752,7 +1752,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
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 `
|
||||
<span class="stream-card-prop${wsLink}" title="${t('color_strip.weather.source')}">${ICON_LINK_SOURCE} ${escapeHtml(wsName)}</span>
|
||||
|
||||
@@ -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 `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','home_assistant','ha-sources','data-id','${conn.source_id}')}">
|
||||
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${ICON_HOME}</span>
|
||||
<div>
|
||||
@@ -283,7 +284,7 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
|
||||
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
|
||||
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
|
||||
|
||||
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
|
||||
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${ICON_RADIO}</span>
|
||||
<div>
|
||||
@@ -914,7 +915,7 @@ export function stopUptimeTimer(): void {
|
||||
|
||||
// React to global server events when dashboard tab is active
|
||||
function _isDashboardActive(): boolean {
|
||||
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
|
||||
return isActiveTab('dashboard');
|
||||
}
|
||||
|
||||
let _eventDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
@@ -749,13 +749,13 @@ export function showGameEventMonitor(integrationId: string) {
|
||||
showGameIntegrationEditor(integrationId);
|
||||
}
|
||||
|
||||
// ── Load function (called from streams.ts) ──
|
||||
// ── Load function (called from integrations.ts) ──
|
||||
|
||||
export async function loadGameIntegrations() {
|
||||
await Promise.all([
|
||||
gameIntegrationsCache.fetch(),
|
||||
gameAdaptersCache.fetch(),
|
||||
]);
|
||||
// Streams.ts handles rendering via its own renderPictureSourcesList
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
// Integrations.ts handles rendering via loadIntegrations
|
||||
if ((window as any).loadIntegrations) (window as any).loadIntegrations();
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
|
||||
// Crosslinks
|
||||
const haLink = haSource
|
||||
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${target.ha_source_id}')`
|
||||
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${target.ha_source_id}')`
|
||||
: '';
|
||||
const cssLink = cssSource
|
||||
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { HomeAssistantSource } from '../types.ts';
|
||||
|
||||
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
||||
@@ -136,7 +135,7 @@ export async function saveHASource(): Promise<void> {
|
||||
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
||||
haSourceModal.forceClose();
|
||||
haSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
haSourceModal.showError(e.message);
|
||||
@@ -182,7 +181,7 @@ export async function deleteHASource(sourceId: string): Promise<void> {
|
||||
}
|
||||
showToast(t('ha_source.deleted'), 'success');
|
||||
haSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Integrations — Weather, Home Assistant, MQTT, Game integration sources.
|
||||
* Extracted from streams.ts to a dedicated top-level tab.
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { showToast, setTabRefreshing } from '../core/ui.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
||||
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
||||
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
|
||||
// ── Bulk action handlers ──
|
||||
|
||||
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
||||
return async (ids: string[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t(toast), 'success');
|
||||
cache.invalidate();
|
||||
await loadIntegrations();
|
||||
};
|
||||
}
|
||||
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
||||
|
||||
// ── Card section instances ──
|
||||
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
|
||||
|
||||
// Re-render integrations when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
|
||||
|
||||
// ── Loading state ──
|
||||
|
||||
let _integrationsLoading = false;
|
||||
|
||||
// ── Tree navigation ──
|
||||
|
||||
let _integrationsTreeTriggered = false;
|
||||
|
||||
const _integrationsTree = new TreeNav('integrations-tree-nav', {
|
||||
onSelect: (key) => {
|
||||
_integrationsTreeTriggered = true;
|
||||
switchIntegrationTab(key);
|
||||
_integrationsTreeTriggered = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tab switching ──
|
||||
|
||||
export function switchIntegrationTab(tabKey: string) {
|
||||
document.querySelectorAll('.stream-tab-panel[id^="integration-tab-"]').forEach(panel =>
|
||||
panel.classList.toggle('active', panel.id === `integration-tab-${tabKey}`)
|
||||
);
|
||||
setActiveSubTab('integrations', tabKey);
|
||||
updateSubTabHash('integrations', tabKey);
|
||||
if (!_integrationsTreeTriggered) {
|
||||
_integrationsTree.setActive(tabKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab-to-section mapping ──
|
||||
|
||||
const _integrationSectionMap: Record<string, CardSection[]> = {
|
||||
weather: [csWeatherSources],
|
||||
home_assistant: [csHASources],
|
||||
mqtt: [csMQTTSources],
|
||||
game: [csGameIntegrations],
|
||||
};
|
||||
|
||||
// ── Load integrations ──
|
||||
|
||||
export async function loadIntegrations() {
|
||||
if (_integrationsLoading) return;
|
||||
_integrationsLoading = true;
|
||||
if (!csWeatherSources.isMounted()) setTabRefreshing('integrations-list', true);
|
||||
try {
|
||||
await Promise.all([
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
mqttSourcesCache.fetch(),
|
||||
gameIntegrationsCache.fetch(),
|
||||
gameAdaptersCache.fetch(),
|
||||
]);
|
||||
renderIntegrationsList();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Error loading integrations:', error);
|
||||
document.getElementById('integrations-list')!.innerHTML = `
|
||||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
||||
`;
|
||||
} finally {
|
||||
_integrationsLoading = false;
|
||||
setTabRefreshing('integrations-list', false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
function renderIntegrationsList() {
|
||||
const container = document.getElementById('integrations-list')!;
|
||||
const activeTab = getActiveSubTab('integrations')!;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
|
||||
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
||||
];
|
||||
|
||||
// Build tree navigation structure
|
||||
const treeGroups = [
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
|
||||
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
|
||||
];
|
||||
|
||||
// Build card items
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
|
||||
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||
|
||||
if (csWeatherSources.isMounted()) {
|
||||
// Incremental update: reconcile cards in-place
|
||||
_integrationsTree.updateCounts({
|
||||
weather: _cachedWeatherSources.length,
|
||||
home_assistant: _cachedHASources.length,
|
||||
mqtt: _cachedMQTTSources.length,
|
||||
game: _cachedGameIntegrations.length,
|
||||
});
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csHASources.reconcile(haSourceItems);
|
||||
csMQTTSources.reconcile(mqttSourceItems);
|
||||
csGameIntegrations.reconcile(gameIntegrationItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
const panels = tabs.map(tab => {
|
||||
let panelContent = '';
|
||||
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
|
||||
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]);
|
||||
|
||||
// Event delegation for card actions
|
||||
initWeatherSourceDelegation(container);
|
||||
initHASourceDelegation(container);
|
||||
initMQTTSourceDelegation(container);
|
||||
|
||||
// Render tree sidebar
|
||||
_integrationsTree.update(treeGroups, activeTab);
|
||||
_integrationsTree.observeSections('integrations-list', {
|
||||
'weather-sources': 'weather',
|
||||
'ha-sources': 'home_assistant',
|
||||
'mqtt-sources': 'mqtt',
|
||||
'game-integrations': 'game',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ window.Chart = Chart; // expose globally for targets.js, dashboard.js
|
||||
import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { dashboardPollInterval } from '../core/state.ts';
|
||||
import { isActiveTab } from '../core/tab-registry.ts';
|
||||
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
|
||||
|
||||
const MAX_SAMPLES = 120;
|
||||
@@ -387,7 +388,6 @@ document.addEventListener('visibilitychange', () => {
|
||||
stopPerfPolling();
|
||||
} else {
|
||||
// Only resume if dashboard is active
|
||||
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||
if (activeTab === 'dashboard') startPerfPolling();
|
||||
if (isActiveTab('dashboard')) startPerfPolling();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
|
||||
import { EntityPalette } from '../core/entity-palette.ts';
|
||||
import { navigateToCard } from '../core/navigation.ts';
|
||||
import { isActiveTab } from '../core/tab-registry.ts';
|
||||
import type { ScenePreset } from '../types.ts';
|
||||
|
||||
let _editingId: string | null = null;
|
||||
@@ -532,7 +533,7 @@ export function initScenePresetDelegation(container: HTMLElement): void {
|
||||
|
||||
function _reloadScenesTab(): void {
|
||||
// Reload automations tab (which includes scenes section)
|
||||
if ((localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
|
||||
if (isActiveTab('automations')) {
|
||||
if (typeof window.loadAutomations === 'function') window.loadAutomations();
|
||||
}
|
||||
// Also refresh dashboard (scene presets section)
|
||||
|
||||
@@ -22,9 +22,6 @@ import {
|
||||
_cachedAudioSources,
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
_cachedWeatherSources,
|
||||
_cachedHASources,
|
||||
_cachedMQTTSources, mqttSourcesCache,
|
||||
_cachedAudioTemplates,
|
||||
_cachedCSPTemplates,
|
||||
_csptModalFilters, set_csptModalFilters,
|
||||
@@ -36,12 +33,10 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, haSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, assetsCache, _cachedAssets, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
gradientsCache, GradientEntity,
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
audioProcessingTemplatesCache, _cachedAudioProcessingTemplates,
|
||||
audioFilterDefsCache,
|
||||
} from '../core/state.ts';
|
||||
@@ -53,15 +48,12 @@ import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
||||
import { createAudioProcessingTemplateCard } from './audio-processing-templates.ts';
|
||||
import {
|
||||
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
|
||||
@@ -69,10 +61,8 @@ import {
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
|
||||
ICON_GAMEPAD,
|
||||
getAssetTypeIcon,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
@@ -108,9 +98,6 @@ const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', ic
|
||||
const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }];
|
||||
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
|
||||
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
||||
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
const _aptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-processing-templates', audioProcessingTemplatesCache, 'audio_processing.deleted') }];
|
||||
@@ -180,9 +167,6 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
|
||||
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
@@ -297,16 +281,11 @@ export async function loadPictureSources() {
|
||||
audioSourcesCache.fetch(),
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
mqttSourcesCache.fetch(),
|
||||
assetsCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
csptCache.fetch(),
|
||||
gradientsCache.fetch(),
|
||||
gameIntegrationsCache.fetch(),
|
||||
gameAdaptersCache.fetch(),
|
||||
audioProcessingTemplatesCache.fetch(),
|
||||
audioFilterDefsCache.data.length === 0 ? audioFilterDefsCache.fetch() : Promise.resolve(audioFilterDefsCache.data),
|
||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||
@@ -338,7 +317,7 @@ export function switchStreamTab(tabKey: string) {
|
||||
document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel =>
|
||||
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
|
||||
);
|
||||
localStorage.setItem('activeStreamTab', tabKey);
|
||||
setActiveSubTab('streams', tabKey);
|
||||
updateSubTabHash('streams', tabKey);
|
||||
// Update tree active state (unless the tree triggered this switch)
|
||||
if (!_streamsTreeTriggered) {
|
||||
@@ -361,10 +340,6 @@ const _streamSectionMap = {
|
||||
audio_processing: [csAudioProcessingTemplates],
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
weather: [csWeatherSources],
|
||||
home_assistant: [csHASources],
|
||||
mqtt: [csMQTTSources],
|
||||
game: [csGameIntegrations],
|
||||
};
|
||||
|
||||
type StreamCardRenderer = (stream: any) => string;
|
||||
@@ -416,7 +391,7 @@ const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
|
||||
|
||||
function renderPictureSourcesList(streams: any) {
|
||||
const container = document.getElementById('streams-list')!;
|
||||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||||
const activeTab = getActiveSubTab('streams')!;
|
||||
|
||||
const renderStreamCard = (stream: any) => {
|
||||
const typeIcon = getPictureSourceIcon(stream.stream_type);
|
||||
@@ -590,11 +565,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'audio_processing', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_processing', count: audioProcessingTemplates.length },
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
|
||||
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
|
||||
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
||||
];
|
||||
|
||||
// Build tree navigation structure
|
||||
@@ -652,15 +623,6 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'integrations_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, titleKey: 'tree.group.integrations',
|
||||
children: [
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
|
||||
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
|
||||
children: [
|
||||
@@ -812,12 +774,8 @@ function renderPictureSourcesList(streams: any) {
|
||||
const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
|
||||
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||
const audioProcessingTemplateItems = csAudioProcessingTemplates.applySortOrder(audioProcessingTemplates.map(t => ({ key: t.id, html: createAudioProcessingTemplateCard(t) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
@@ -837,11 +795,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
audio_processing: audioProcessingTemplates.length,
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
home_assistant: _cachedHASources.length,
|
||||
mqtt: _cachedMQTTSources.length,
|
||||
assets: _cachedAssets.length,
|
||||
game: _cachedGameIntegrations.length,
|
||||
});
|
||||
csRawStreams.reconcile(rawStreamItems);
|
||||
csRawTemplates.reconcile(rawTemplateItems);
|
||||
@@ -858,11 +812,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
csVideoStreams.reconcile(videoItems);
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csHASources.reconcile(haSourceItems);
|
||||
csMQTTSources.reconcile(mqttSourceItems);
|
||||
csAssets.reconcile(assetItems);
|
||||
csGameIntegrations.reconcile(gameIntegrationItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
const panels = tabs.map(tab => {
|
||||
@@ -880,24 +830,17 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'audio_processing') panelContent = csAudioProcessingTemplates.render(audioProcessingTemplateItems);
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
|
||||
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
|
||||
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioCapture, csAudioProcessed, csAudioTemplates, csAudioProcessingTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csMQTTSources, csAssets, csGameIntegrations]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioCapture, csAudioProcessed, csAudioTemplates, csAudioProcessingTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csAssets]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
initWeatherSourceDelegation(container);
|
||||
initHASourceDelegation(container);
|
||||
initMQTTSourceDelegation(container);
|
||||
initAudioSourceDelegation(container);
|
||||
initAssetDelegation(container);
|
||||
|
||||
@@ -917,11 +860,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
'audio-processing-templates': 'audio_processing',
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
'ha-sources': 'home_assistant',
|
||||
'mqtt-sources': 'mqtt',
|
||||
'assets': 'assets',
|
||||
'game-integrations': 'game',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
|
||||
import { getActiveSubTab, setActiveSubTab, callTabLoader, callSubTabSwitcher, getSubTabConfig, getTabConfig } from '../core/tab-registry.ts';
|
||||
|
||||
/** Parse location.hash into {tab, subTab}. */
|
||||
export function parseHash(): { tab?: string; subTab?: string } {
|
||||
@@ -44,19 +45,12 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
|
||||
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
|
||||
|
||||
if (updateHash && !_suppressHashUpdate) {
|
||||
const subTab = name === 'targets'
|
||||
? (localStorage.getItem('activeTargetSubTab') || 'led')
|
||||
: name === 'streams'
|
||||
? (localStorage.getItem('activeStreamTab') || 'raw')
|
||||
: name === 'automations'
|
||||
? (localStorage.getItem('activeAutomationTab') || 'automations')
|
||||
: null;
|
||||
_setHash(name, subTab);
|
||||
_setHash(name, getActiveSubTab(name));
|
||||
}
|
||||
|
||||
if (name === 'dashboard') {
|
||||
// Use window.* to avoid circular imports with feature modules
|
||||
if (!skipLoad && apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
|
||||
if (!skipLoad && apiKey) callTabLoader(name);
|
||||
} else {
|
||||
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
||||
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
|
||||
@@ -66,15 +60,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
|
||||
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
|
||||
}
|
||||
if (!apiKey || skipLoad) return;
|
||||
if (name === 'streams') {
|
||||
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
|
||||
} else if (name === 'targets') {
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else if (name === 'automations') {
|
||||
if (typeof window.loadAutomations === 'function') window.loadAutomations();
|
||||
} else if (name === 'graph') {
|
||||
if (typeof window.loadGraphEditor === 'function') window.loadGraphEditor();
|
||||
}
|
||||
callTabLoader(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +73,7 @@ export function initTabs(): void {
|
||||
saved = hashRoute.tab;
|
||||
// Pre-set sub-tab so the sub-tab switch functions pick it up
|
||||
if (hashRoute.subTab) {
|
||||
if (saved === 'targets') localStorage.setItem('activeTargetSubTab', hashRoute.subTab);
|
||||
if (saved === 'streams') localStorage.setItem('activeStreamTab', hashRoute.subTab);
|
||||
if (saved === 'automations') localStorage.setItem('activeAutomationTab', hashRoute.subTab);
|
||||
setActiveSubTab(saved, hashRoute.subTab);
|
||||
}
|
||||
} else {
|
||||
saved = localStorage.getItem('activeTab');
|
||||
@@ -115,14 +99,14 @@ export function startAutoRefresh(): void {
|
||||
setRefreshInterval(setInterval(() => {
|
||||
if (!apiKey || document.hidden) return;
|
||||
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||
const cfg = getTabConfig(activeTab);
|
||||
if (!cfg?.autoRefresh) return;
|
||||
// Skip refresh while user interacts with a picker or slider
|
||||
if (activeTab === 'targets') {
|
||||
// Skip refresh while user interacts with a picker or slider
|
||||
const panel = document.getElementById('targets-panel-content');
|
||||
if (panel && panel.contains(document.activeElement) && document.activeElement!.matches('input')) return;
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else if (activeTab === 'dashboard') {
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard();
|
||||
}
|
||||
callTabLoader(activeTab);
|
||||
}, dashboardPollInterval));
|
||||
}
|
||||
|
||||
@@ -142,15 +126,11 @@ export function handlePopState(): void {
|
||||
}
|
||||
|
||||
if (hashRoute.subTab) {
|
||||
if (hashRoute.tab === 'targets') {
|
||||
const currentSub = localStorage.getItem('activeTargetSubTab');
|
||||
if (hashRoute.subTab !== currentSub && typeof window.switchTargetSubTab === 'function') {
|
||||
window.switchTargetSubTab(hashRoute.subTab);
|
||||
}
|
||||
} else if (hashRoute.tab === 'streams') {
|
||||
const currentSub = localStorage.getItem('activeStreamTab');
|
||||
if (hashRoute.subTab !== currentSub && typeof window.switchStreamTab === 'function') {
|
||||
window.switchStreamTab(hashRoute.subTab);
|
||||
const subCfg = getSubTabConfig(hashRoute.tab);
|
||||
if (subCfg) {
|
||||
const currentSub = localStorage.getItem(subCfg.storageKey);
|
||||
if (hashRoute.subTab !== currentSub) {
|
||||
callSubTabSwitcher(hashRoute.tab, hashRoute.subTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
|
||||
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import type { OutputTarget, LedOutputTarget } from '../types.ts';
|
||||
import { bindableSourceId, bindableValue } from '../types.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
@@ -99,7 +100,7 @@ const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_lig
|
||||
|
||||
// Re-render targets tab when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
|
||||
if (apiKey && isActiveTab('targets')) loadTargetsTab();
|
||||
});
|
||||
|
||||
// --- FPS sparkline history and chart instances for target cards ---
|
||||
@@ -568,7 +569,7 @@ export function switchTargetSubTab(tabKey: any) {
|
||||
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
|
||||
(panel as HTMLElement).classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
||||
);
|
||||
localStorage.setItem('activeTargetSubTab', tabKey);
|
||||
setActiveSubTab('targets', tabKey);
|
||||
updateSubTabHash('targets', tabKey);
|
||||
// Update tree active state (unless the tree triggered this switch)
|
||||
if (!_treeTriggered) {
|
||||
@@ -649,7 +650,7 @@ export async function loadTargetsTab() {
|
||||
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
||||
updateTabBadge('targets', runningCount);
|
||||
|
||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||
const activeSubTab = getActiveSubTab('targets')!;
|
||||
|
||||
// Build tree navigation structure
|
||||
const treeGroups = [
|
||||
|
||||
@@ -1303,7 +1303,7 @@ export function createValueSourceCard(src: ValueSource) {
|
||||
const entityId = (src as any).entity_id || '';
|
||||
const attr = (src as any).attribute;
|
||||
const haBadge = haSrc
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.ha_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')">${ICON_HOME} ${escapeHtml(haName)}</span>`
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.ha_source'))}" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')">${ICON_HOME} ${escapeHtml(haName)}</span>`
|
||||
: `<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>`;
|
||||
propsHtml = `
|
||||
${haBadge}
|
||||
|
||||
@@ -12,7 +12,6 @@ import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { WeatherSource } from '../types.ts';
|
||||
|
||||
const ICON_WEATHER = `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`;
|
||||
@@ -161,7 +160,7 @@ export async function saveWeatherSource(): Promise<void> {
|
||||
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
||||
weatherSourceModal.forceClose();
|
||||
weatherSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
weatherSourceModal.showError(e.message);
|
||||
@@ -207,7 +206,7 @@ export async function deleteWeatherSource(sourceId: string): Promise<void> {
|
||||
}
|
||||
showToast(t('weather_source.deleted'), 'success');
|
||||
weatherSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"section.expand_all": "Expand all sections",
|
||||
"section.collapse_all": "Collapse all sections",
|
||||
"streams.title": "Sources",
|
||||
"integrations.title": "Integrations",
|
||||
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
|
||||
"streams.group.raw": "Sources",
|
||||
"streams.group.raw_templates": "Engine Templates",
|
||||
|
||||
@@ -461,6 +461,7 @@
|
||||
"section.expand_all": "Развернуть все секции",
|
||||
"section.collapse_all": "Свернуть все секции",
|
||||
"streams.title": "Источники",
|
||||
"integrations.title": "Интеграции",
|
||||
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
|
||||
"streams.group.raw": "Источники",
|
||||
"streams.group.raw_templates": "Шаблоны движка",
|
||||
|
||||
@@ -461,6 +461,7 @@
|
||||
"section.expand_all": "全部展开",
|
||||
"section.collapse_all": "全部折叠",
|
||||
"streams.title": "源",
|
||||
"integrations.title": "集成",
|
||||
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
|
||||
"streams.group.raw": "源",
|
||||
"streams.group.raw_templates": "引擎模板",
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span></button>
|
||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span></button>
|
||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
||||
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
|
||||
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
|
||||
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
||||
@@ -145,6 +146,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-integrations" role="tabpanel" aria-labelledby="tab-btn-integrations">
|
||||
<div class="tree-layout">
|
||||
<nav class="tree-sidebar" id="integrations-tree-nav"></nav>
|
||||
<div class="tree-content" id="integrations-list">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-graph" role="tabpanel" aria-labelledby="tab-btn-graph">
|
||||
<div id="graph-editor-content">
|
||||
<div class="loading-spinner"></div>
|
||||
|
||||
Reference in New Issue
Block a user